Files
crewli/resources/Crewli - Artist Timetable Management/timetable.jsx

1134 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
<div
className={"cw-block" + (isSelected ? " is-selected" : "") + (conflicting ? " is-conflict" : "") + (compact ? " is-compact" : "") + (isCancelled ? " is-cancelled" : "")}
style={blockStyle}
onMouseDown={(e) => { if (e.button !== 0) return; onStartDragMove(e, perf); }}
onClick={(e) => { e.stopPropagation(); onSelect(perf, e.currentTarget.getBoundingClientRect()); }}
>
<div className="cw-block-body">
<div className="cw-block-row1">
<span className="cw-block-name" title={artist.name}>{artist.name}</span>
{!compact && artist.genre && width > 110 && (
<span className="cw-block-genre" title={artist.genre}>{artist.genre}</span>
)}
<div className="cw-block-warn-row">
{capWarn && (
<span className="cw-block-warn" title={`Trekkracht ${artist.draw.toLocaleString('nl-NL')} > capaciteit ${stage.capacity.toLocaleString('nl-NL')}`}>
<svg width="12" height="12" viewBox="0 0 12 12">
<path d="M6 1 L11 10 L1 10 Z" fill="#e89a3c" stroke="#7a5018" strokeWidth=".7" strokeLinejoin="round"/>
<rect x="5.4" y="4.5" width="1.2" height="3" fill="#7a5018"/>
<rect x="5.4" y="8.2" width="1.2" height="1.2" fill="#7a5018"/>
</svg>
</span>
)}
{conflicting && (
<span className="cw-block-warn" title="Overlap op deze stage">
<svg width="12" height="12" viewBox="0 0 12 12">
<circle cx="6" cy="6" r="5" fill="#d63d4b"/>
<rect x="5.4" y="2.5" width="1.2" height="4" fill="white"/>
<rect x="5.4" y="7.8" width="1.2" height="1.2" fill="white"/>
</svg>
</span>
)}
{showPercent && width > 86 && !compact && (
<span className="cw-block-pct" title={`Advancing ${adv.done}/${adv.total}`}>{advPct}%</span>
)}
</div>
</div>
{!compact && (
<div className="cw-block-row2">
{showTime && <span className="cw-block-time">{time}</span>}
{showPercent && width > 86 && (
<span className="cw-block-adv-bar" title={`Advancing ${adv.done}/${adv.total}`}>
<span className="cw-block-adv-bar-fill" style={{ width: advPct + "%" }}></span>
</span>
)}
</div>
)}
{compact && showTime && (
<span className="cw-block-time cw-block-time-inline">{time}</span>
)}
</div>
<span className="cw-block-resize"
onMouseDown={(e) => { e.stopPropagation(); onStartDragResize(e, perf); }} />
{b2bRight && <span className="cw-b2b-right" title="Back-to-back changeover" />}
{b2bLeft && <span className="cw-b2b-left" />}
</div>
);
}
// ─── 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 (
<div className="cw-axis" style={{ width: totalMin * pxPerMin }}>
{hours.map(({ m, h }) => (
<div key={m} className="cw-axis-tick" style={{ left: m * pxPerMin }}>
<span className="cw-axis-label">{String(Math.floor(h)).padStart(2, "0")}:00</span>
</div>
))}
</div>
);
}
function GridBg({ pxPerMin, totalMin, totalH, rowDividers }) {
const halfHourW = 30 * pxPerMin;
const hourW = 60 * pxPerMin;
return (
<>
<div className="cw-grid-bg"
style={{
width: totalMin * pxPerMin, height: totalH,
backgroundImage:
`linear-gradient(to right, rgba(20,30,45,.085) 1px, transparent 1px),` +
`linear-gradient(to right, rgba(20,30,45,.04) 1px, transparent 1px)`,
backgroundSize: `${hourW}px 100%, ${halfHourW}px 100%`,
}} />
{rowDividers.map((y, i) => (
<div key={i} className="cw-grid-row-divider"
style={{ top: y, width: totalMin * pxPerMin }} />
))}
</>
);
}
// ─── 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 (
<div ref={ref} className="cw-pq-msel">
<button type="button"
className={"cw-pq-msel-btn" + (open ? " is-open" : "")}
onClick={() => setOpen(!open)}>
<span className="cw-pq-msel-lbl">
<span className="cw-pq-msel-icon"></span>
Status: <b>{lbl}</b>
</span>
<span className="cw-pq-msel-chev"></span>
</button>
{open && (
<div className="cw-pq-msel-menu">
<div className="cw-pq-msel-toolbar">
<button className="cw-pq-msel-link" onClick={() => setAll(true)}>Alle aan</button>
<span className="cw-pq-msel-sep">·</span>
<button className="cw-pq-msel-link" onClick={() => setAll(false)}>Alle uit</button>
</div>
<div className="cw-pq-msel-list">
{statuses.map(k => {
const s = STATUS[k];
const on = !!statusOn[k];
const cnt = counts[k] || 0;
return (
<label key={k} className={"cw-pq-msel-item" + (on ? " is-on" : "")}>
<input type="checkbox" checked={on} onChange={() => toggle(k)} />
<span className="cw-status-dot" style={{ background: s.dot }} />
<span className="cw-pq-msel-item-lbl">{s.label}</span>
<span className="cw-pq-msel-item-cnt">{cnt}</span>
</label>
);
})}
</div>
</div>
)}
</div>
);
}
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 (
<div key={it.kind + ":" + it.id}
className={"cw-parking-item" + (isSelected ? " is-selected" : "") + (it.status === "cancelled" ? " is-cancelled" : "")}
onMouseDown={(e) => it.kind === "pending"
? onStartDragPending(e, it.src, it)
: onStartDragParked(e, it.src, it)}>
<span className="cw-avatar">{it.artist.initials}</span>
<div className="cw-parking-item-body">
<div className="cw-parking-item-name">{it.artist.name}</div>
<div className="cw-parking-item-meta">
<span className="cw-status-dot" style={{ background: STATUS[it.status].dot }} />
<span className="cw-parking-item-status">{STATUS[it.status].label}</span>
<span className="cw-parking-item-sep">·</span>
<span className="cw-parking-item-genre">{it.artist.genre}</span>
{it.dur != null && (
<>
<span className="cw-parking-item-sep">·</span>
<span className="cw-parking-item-dur">{Math.floor(it.dur/60)}u{it.dur%60 ? ` ${it.dur%60}m` : ""}</span>
</>
)}
</div>
</div>
</div>
);
};
const genreLabel = filterGenre === "__all" ? "Alle genres" : filterGenre;
return (
<aside className={"cw-parking" + (isDropTarget ? " is-drop-target" : "") + (isDragOrigin ? " is-drag-origin" : "")}>
<header className="cw-parking-hd">
<div className="cw-parking-title">
Wachtrij
<span className="cw-parking-meta">{items.length}</span>
</div>
</header>
<div className="cw-pq-filters">
{/* Top row: search field + group-by-status toggle */}
<div className="cw-pq-row">
<label className="cw-pq-search">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<circle cx="7" cy="7" r="4"/><path d="M10 10 L13.5 13.5"/>
</svg>
<input type="text" placeholder="Zoek artiest…"
value={search} onChange={(e) => setSearch(e.target.value)} />
{search && (
<button className="cw-pq-search-x" onClick={() => setSearch("")} aria-label="Wissen">×</button>
)}
</label>
<button type="button"
className={"cw-pq-icon-btn" + (groupBy === "status" ? " is-on" : "")}
onClick={() => setGroupBy(groupBy === "status" ? "none" : "status")}
title={groupBy === "status" ? "Groepering: status — klik om uit te zetten" : "Groepering: uit — klik voor groep op status"}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
<path d="M3 3.5 H11 M3 7 H11 M3 10.5 H11"/>
<circle cx="1.5" cy="3.5" r=".7" fill="currentColor" stroke="none"/>
<circle cx="1.5" cy="7" r=".7" fill="currentColor" stroke="none"/>
<circle cx="1.5" cy="10.5" r=".7" fill="currentColor" stroke="none"/>
</svg>
</button>
</div>
{/* Status: multi-select dropdown */}
<StatusMultiSelect
statuses={STATUSES} STATUS={STATUS} statusOn={statusOn}
counts={STATUSES.reduce((m, k) => { m[k] = items.filter(it => it.status === k).length; return m; }, {})}
onToggle={toggleStatus}
onSet={(next) => setStatusOn(next)}
/>
{/* Genre pill row — horizontally scrollable */}
<div className="cw-pq-genres" role="radiogroup" aria-label="Filter op genre">
<button className={"cw-pq-genre-pill" + (filterGenre === "__all" ? " is-on" : "")}
onClick={() => setFilterGenre("__all")}>
Alle
</button>
{GENRES.map(g => {
const cnt = items.filter(it => it.artist.genre === g).length;
if (cnt === 0) return null;
return (
<button key={g}
className={"cw-pq-genre-pill" + (filterGenre === g ? " is-on" : "")}
onClick={() => setFilterGenre(filterGenre === g ? "__all" : g)}>
{g}
<span className="cw-pq-genre-cnt">{cnt}</span>
</button>
);
})}
</div>
</div>
<div className="cw-parking-body">
{filtered.length === 0 && (
<div className="cw-parking-empty">
{items.length === 0
? "Wachtrij is leeg"
: hiddenByStatus === items.length
? "Alle statussen uitgeschakeld"
: "Geen items voldoen aan dit filter"}
</div>
)}
{groups.map(g => (
<section key={g.key} className="cw-parking-section">
{g.label && (
<h5>
<span className="cw-parking-group-label">
<span className="cw-status-dot" style={{ background: STATUS[g.key].dot }} />
{g.label}
</span>
<span className="cw-parking-count">{g.items.length}</span>
</h5>
)}
<div className="cw-parking-list">
{g.items.map(renderItem)}
</div>
</section>
))}
</div>
<footer className="cw-parking-ft">
{isDragOrigin
? <span><b>Loslaten = blijft in wachtrij.</b> Sleep naar een stage om te plannen.</span>
: <span>Sleep een block hierheen om te <b>parkeren</b>, klik voor details</span>}
</footer>
</aside>
);
}
// ─── 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 (
<div className="cw-tt">
<div className="cw-tt-corner" style={{ width: STAGE_COL_W }}>
<span className="cw-tt-corner-lbl">Stages</span>
<span className="cw-tt-corner-meta">{displayedStages.length} actief</span>
</div>
<div className="cw-tt-axis-wrap">
<div ref={axisInnerRef} className="cw-tt-axis-inner">
<TimeAxis pxPerMin={pxPerMin} totalMin={totalMin} startHour={window.CrewliData.TIME.startHour} />
</div>
</div>
<div className="cw-tt-stages" style={{ width: STAGE_COL_W }}>
{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 (
<div key={s.id}
className={"cw-tt-stage" + (isDragging ? " is-dragging-row" : "") + (stageDrag ? " is-reflowing" : "")}
style={{ height: rowHeights[i], transform, zIndex: isDragging ? 5 : null }}>
<span className="cw-stage-handle"
title="Sleep om volgorde te wijzigen"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => { e.stopPropagation(); startStageReorder(e, i); }}>
<svg width="10" height="14" viewBox="0 0 10 14"><circle cx="3" cy="3" r="1.1"/><circle cx="3" cy="7" r="1.1"/><circle cx="3" cy="11" r="1.1"/><circle cx="7" cy="3" r="1.1"/><circle cx="7" cy="7" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</span>
<span className="cw-stage-swatch" style={{ background: s.color }} />
<div className="cw-tt-stage-info">
<span className="cw-tt-stage-name">{s.name}</span>
<span className="cw-tt-stage-cap">cap. {s.capacity.toLocaleString("nl-NL")}</span>
</div>
{(() => {
const stageConflicts = performances.filter(p => p.stage_id === s.id && conflicts.has(p.id)).length;
if (stageConflicts > 0) {
return (
<span className="cw-tt-stage-conflict" title={`${stageConflicts} conflict${stageConflicts > 1 ? "en" : ""} op deze stage`}>
{stageConflicts}
</span>
);
}
return null;
})()}
<button className="cw-stage-edit-btn"
title="Stage bewerken"
onClick={(e) => { e.stopPropagation(); if (stageDrag) return; onEditStage(s); }}>
Bewerken
</button>
</div>
);})}
{displayedStages.length === 0 && (
<div className="cw-tt-empty-stages">Geen stages op deze dag.</div>
)}
</div>
<div className="cw-tt-canvas" ref={canvasRef}>
<div className="cw-tt-canvas-inner" style={{ width: totalMin * pxPerMin, height: totalH || 200 }}>
<GridBg pxPerMin={pxPerMin} totalMin={totalMin} totalH={totalH} rowDividers={rowDividers} />
{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 (
<div key={s.id}
className={"cw-tt-row" + (isRowDragging ? " is-dragging-row" : "") + (stageDrag ? " is-reflowing" : "")}
style={{ top: rowTops[i], height: rowHeights[i], transform: rowTransform, zIndex: isRowDragging ? 4 : null }}
onMouseDown={(e) => 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 (
<Block key={p.id}
perf={renderPerf} artist={artist} stage={s} sections={sections}
pxPerMin={pxPerMin}
laneTop={laneTop} laneHeight={laneHeight}
conflicting={conflicts.has(p.id)}
capWarn={window.CrewliHelpers.isCapacityWarn(artist, s)}
b2bRight={b2bRightSet.has(p.id)} b2bLeft={b2bLeftSet.has(p.id)}
isSelected={selectedId === p.id}
showPercent={showPercent}
onSelect={(perf, rect) => { setSelectedId(perf.id); onSelectPerf(perf, rect); }}
onStartDragMove={startDragMove}
onStartDragResize={startDragResize}
/>
);
})}
</div>
);
})}
{/* 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 (
<div className="cw-tt-row cw-tt-row-ghost"
style={{ top: rowTops[newRow], height: rowHeights[newRow] }}>
<Block perf={renderPerf} artist={artist} stage={targetStage} sections={sections}
pxPerMin={pxPerMin}
laneTop={ghostLaneTop} laneHeight={LANE_BLOCK_H}
conflicting={false} capWarn={false}
b2bRight={false} b2bLeft={false}
isSelected={false} showPercent={showPercent}
onSelect={() => {}} onStartDragMove={() => {}} onStartDragResize={() => {}}
/>
</div>
);
})()}
{/* 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 (
<div className="cw-tt-row cw-tt-row-ghost"
style={{ top: rowTops[rowIdx], height: rowHeights[rowIdx] }}>
<div className="cw-tt-create-ghost"
style={{ left, width, top: ghostLaneTop, height: LANE_BLOCK_H, borderColor: stage.color }}>
<span className="cw-tt-create-ghost-time">
{drag.moved && dur >= 15 ? (
<><b>{dur}m</b> · {window.CrewliHelpers.fmtTime(lo, window.CrewliData.TIME.startHour)}{window.CrewliHelpers.fmtTime(hi, window.CrewliData.TIME.startHour)}</>
) : (
<b>{Math.max(0, dur)}m</b>
)}
</span>
</div>
</div>
);
})()}
{/* 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 (
<div className="cw-tt-row cw-tt-row-ghost"
style={{ top: rowTops[rowIdx], height: rowHeights[rowIdx] }}>
<Block perf={renderPerf} artist={artist} stage={stage} sections={sections}
pxPerMin={pxPerMin}
laneTop={ghostLaneTop} laneHeight={LANE_BLOCK_H}
conflicting={false} capWarn={false}
b2bRight={false} b2bLeft={false}
isSelected={false} showPercent={showPercent}
onSelect={() => {}} onStartDragMove={() => {}} onStartDragResize={() => {}}
/>
</div>
);
})()}
{displayedStages.length === 0 && (
<div className="cw-tt-empty">
<h3>Geen stages op deze dag</h3>
<p>Gebruik <b>Lineup per dag</b> om stages te activeren, of voeg een nieuwe stage toe.</p>
</div>
)}
</div>
</div>
<ParkingColumn
pending={pending} parked={parked}
artists={artists}
onStartDragPending={(e, item, full) => 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 (
<div className="cw-floating-chip" style={{ left: drag.mouseX + 14, top: drag.mouseY + 6 }}>
<span className="cw-avatar">{artist.initials}</span>
<div>
<div className="cw-floating-chip-name">{artist.name}</div>
<div className="cw-floating-chip-hint">Loslaten = parkeren</div>
</div>
</div>
);
})()}
{/* 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 (
<div className="cw-floating-chip" style={{ left: drag.mouseX + 14, top: drag.mouseY + 6 }}>
<span className="cw-avatar">{artist.initials}</span>
<div>
<div className="cw-floating-chip-name">{artist.name}</div>
<div className="cw-floating-chip-hint">Sleep naar tijdvak · loslaten = annuleren</div>
</div>
</div>
);
})()}
</div>
);
}
return { Timetable };
})();
Object.assign(window, { CrewliTimetable: TT });