1134 lines
55 KiB
JavaScript
1134 lines
55 KiB
JavaScript
// 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 });
|