265 lines
11 KiB
JavaScript
265 lines
11 KiB
JavaScript
// Unified popover for both timetable performances and queue items.
|
||
// Design language is the same; the body just adapts to whether a time/stage exists.
|
||
//
|
||
// - Header: avatar + name + meta line (genre · stage OR "In wachtrij" · genre)
|
||
// - Time: tijdvak (or "Geen tijdslot")
|
||
// - Status: dropdown (workflow-ready — disabled options can be added later)
|
||
// - Advancing: progress bar + checklist
|
||
// - Footer: Verwijderen (timetable only) + full-width "Open detailpagina"
|
||
//
|
||
// Off-screen safe: positioning prefers below, flips above when needed,
|
||
// clamps to viewport with margin.
|
||
|
||
const PV = (function () {
|
||
const POP_W = 340;
|
||
const POP_H = 460;
|
||
const MARGIN = 12;
|
||
|
||
function pickPos(anchorRect, preferSide) {
|
||
// Horizontal: preferred side first, then opposite, then clamp.
|
||
const right = anchorRect.right + MARGIN;
|
||
const left = anchorRect.left - POP_W - MARGIN;
|
||
let l, side;
|
||
const fitsRight = right + POP_W < window.innerWidth - MARGIN;
|
||
const fitsLeft = left > MARGIN;
|
||
if (preferSide === "left") {
|
||
if (fitsLeft) { l = left; side = "left"; }
|
||
else if (fitsRight) { l = right; side = "right"; }
|
||
else { l = Math.max(MARGIN, anchorRect.left); side = "right"; }
|
||
} else {
|
||
if (fitsRight) { l = right; side = "right"; }
|
||
else if (fitsLeft) { l = left; side = "left"; }
|
||
else { l = Math.max(MARGIN, anchorRect.left); side = "right"; }
|
||
}
|
||
// Vertical: anchor top-aligned. If overflows bottom, push up. Never go above MARGIN.
|
||
let t = anchorRect.top - 8;
|
||
if (t + POP_H > window.innerHeight - MARGIN) {
|
||
t = window.innerHeight - POP_H - MARGIN;
|
||
}
|
||
if (t < MARGIN) t = MARGIN;
|
||
return { left: l, top: t, side };
|
||
}
|
||
|
||
// ─── Status dropdown (workflow-ready, supports disabled) ───────────
|
||
function StatusDropdown({ value, onChange, disabledKeys }) {
|
||
const STATUS = window.CrewliHelpers.STATUS;
|
||
const ORDER = ["concept", "requested", "option", "confirmed", "contracted", "cancelled"];
|
||
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 cur = STATUS[value];
|
||
const dis = disabledKeys || new Set();
|
||
|
||
return (
|
||
<div className="cw-pop-dd" ref={ref}>
|
||
<button type="button" className="cw-pop-dd-btn"
|
||
onClick={() => setOpen(o => !o)}
|
||
style={{ background: cur.pillBg, color: cur.pillFg, borderColor: cur.dot + "80" }}>
|
||
<span className="cw-status-dot" style={{ background: cur.dot }}></span>
|
||
<span className="cw-pop-dd-lbl">{cur.label}</span>
|
||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.4" style={{ opacity: .7, marginLeft: 4 }}>
|
||
<path d="M2.5 4 L5 6.5 L7.5 4"/>
|
||
</svg>
|
||
</button>
|
||
{open && (
|
||
<div className="cw-pop-dd-menu" role="listbox">
|
||
{ORDER.map(k => {
|
||
const s = STATUS[k];
|
||
const isCur = k === value;
|
||
const isDisabled = dis.has(k);
|
||
return (
|
||
<button key={k} type="button"
|
||
className={"cw-pop-dd-item" + (isCur ? " is-current" : "") + (isDisabled ? " is-disabled" : "")}
|
||
disabled={isDisabled}
|
||
onClick={() => { if (!isDisabled) { onChange(k); setOpen(false); } }}>
|
||
<span className="cw-status-dot" style={{ background: s.dot, opacity: isDisabled ? .35 : 1 }}></span>
|
||
<span className="cw-pop-dd-item-lbl">{s.label}</span>
|
||
{isCur && (
|
||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginLeft: "auto" }}>
|
||
<path d="M2.5 5.5 L4.5 7.5 L8.5 3.5"/>
|
||
</svg>
|
||
)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Shared body (time + status + advancing) ─────────────────────
|
||
function PopoverBody({ status, time, dur, advDone, advTotal, sections, artist, statusKey, onChangeStatus }) {
|
||
const advPct = Math.round(advDone / advTotal * 100);
|
||
return (
|
||
<>
|
||
<div className="cw-pop-row">
|
||
<div className="cw-pop-time">
|
||
<div className={"cw-pop-time-main" + (time ? "" : " cw-pop-time-empty")}>
|
||
{time || (dur != null ? `${Math.floor(dur/60)}u${dur%60 ? ` ${dur%60}m` : ""}` : "Geen tijdslot")}
|
||
</div>
|
||
<div className="cw-pop-time-sub">
|
||
{time
|
||
? `${Math.floor(dur/60)}u${dur%60 ? ` ${dur%60}m` : ""}`
|
||
: (dur != null ? "Verwachte duur" : "Sleep naar timetable om te plannen")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="cw-pop-section-lbl">Status</div>
|
||
<StatusDropdown value={statusKey} onChange={onChangeStatus} />
|
||
|
||
<div className="cw-pop-section-lbl">
|
||
Advancing
|
||
<span className="cw-pop-section-meta">{advPct}% · {advDone}/{advTotal}</span>
|
||
</div>
|
||
<div className="cw-progress" style={{ marginBottom: 8 }}>
|
||
<div className="cw-progress-fill" style={{ width: advPct + "%" }}></div>
|
||
</div>
|
||
<div className="cw-adv-list">
|
||
{sections.map(s => {
|
||
const done = !!(artist.advance && artist.advance[s.key]);
|
||
return (
|
||
<div key={s.key} className={"cw-adv-row" + (done ? " is-done" : "")}>
|
||
<span className="cw-adv-tick">
|
||
{done ? (
|
||
<svg width="10" height="10" viewBox="0 0 10 10"><path d="M2 5 L4 7 L8 3" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||
) : null}
|
||
</span>
|
||
<span className="cw-adv-label">{s.label}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ─── Performance popover (timetable block) ─────────────────────────
|
||
function Popover({ perf, artist, stage, anchorRect, sections, onChangeStatus, onOpenDetailPage, onClose }) {
|
||
const ref = React.useRef(null);
|
||
const [pos, setPos] = React.useState(() => pickPos(anchorRect, "right"));
|
||
|
||
React.useEffect(() => {
|
||
const update = () => setPos(pickPos(anchorRect, "right"));
|
||
update();
|
||
window.addEventListener("resize", update);
|
||
window.addEventListener("scroll", update, true);
|
||
return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true); };
|
||
}, [anchorRect]);
|
||
|
||
React.useEffect(() => {
|
||
const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
|
||
window.addEventListener("mousedown", onDown);
|
||
return () => window.removeEventListener("mousedown", onDown);
|
||
}, []);
|
||
|
||
const adv = window.CrewliHelpers.advanceCount(artist, sections);
|
||
const time = window.CrewliHelpers.fmtTime(perf.start, window.CrewliData.TIME.startHour) +
|
||
" – " + window.CrewliHelpers.fmtTime(perf.end, window.CrewliData.TIME.startHour);
|
||
const dur = perf.end - perf.start;
|
||
|
||
return (
|
||
<div ref={ref} className="cw-popover" style={{ left: pos.left, top: pos.top }}>
|
||
<div className="cw-pop-arrow" data-side={pos.side}></div>
|
||
<div className="cw-pop-hd">
|
||
<div className="cw-pop-avatar" style={{ background: stage.color + "1f", color: "#1a2530", border: "1px solid " + stage.color + "55" }}>
|
||
{artist.initials}
|
||
</div>
|
||
<div className="cw-pop-titles">
|
||
<div className="cw-pop-name">{artist.name}</div>
|
||
<div className="cw-pop-meta">
|
||
<span className="cw-pop-meta-genre">{artist.genre}</span>
|
||
<span className="cw-pop-meta-sep">·</span>
|
||
<span className="cw-stage-dot" style={{ background: stage.color }}></span>
|
||
{stage.name}
|
||
</div>
|
||
</div>
|
||
<button className="cw-icon-btn" onClick={onClose} aria-label="Sluiten">×</button>
|
||
</div>
|
||
|
||
<PopoverBody
|
||
time={time} dur={dur}
|
||
advDone={adv.done} advTotal={adv.total}
|
||
sections={sections} artist={artist}
|
||
statusKey={perf.status} onChangeStatus={onChangeStatus}
|
||
/>
|
||
|
||
<div className="cw-pop-ft cw-pop-ft-stack">
|
||
<button className="cw-btn cw-btn-primary cw-btn-block"
|
||
onClick={() => onOpenDetailPage(artist, perf)}>
|
||
Open detailpagina →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Queue popover (parking column) ─────────────────────────────────
|
||
function QueuePopover({ item, artist, anchorRect, sections, onChangeStatus, onOpenDetailPage, onClose }) {
|
||
const ref = React.useRef(null);
|
||
const [pos, setPos] = React.useState(() => pickPos(anchorRect, "left"));
|
||
|
||
React.useEffect(() => {
|
||
const update = () => setPos(pickPos(anchorRect, "left"));
|
||
update();
|
||
window.addEventListener("resize", update);
|
||
window.addEventListener("scroll", update, true);
|
||
return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true); };
|
||
}, [anchorRect]);
|
||
|
||
React.useEffect(() => {
|
||
const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
|
||
window.addEventListener("mousedown", onDown);
|
||
return () => window.removeEventListener("mousedown", onDown);
|
||
}, []);
|
||
|
||
const adv = window.CrewliHelpers.advanceCount(artist, sections);
|
||
const dur = item.dur;
|
||
|
||
return (
|
||
<div ref={ref} className="cw-popover cw-popover-queue" style={{ left: pos.left, top: pos.top }}>
|
||
<div className="cw-pop-arrow" data-side={pos.side}></div>
|
||
<div className="cw-pop-hd">
|
||
<div className="cw-pop-avatar" style={{ background: "#e6efe9", color: "#1a2530", border: "1px solid var(--border)" }}>
|
||
{artist.initials}
|
||
</div>
|
||
<div className="cw-pop-titles">
|
||
<div className="cw-pop-name">{artist.name}</div>
|
||
<div className="cw-pop-meta">
|
||
<span className="cw-pop-meta-genre">{artist.genre}</span>
|
||
<span className="cw-pop-meta-sep">·</span>
|
||
<span className="cw-pop-queue-tag">In wachtrij</span>
|
||
</div>
|
||
</div>
|
||
<button className="cw-icon-btn" onClick={onClose} aria-label="Sluiten">×</button>
|
||
</div>
|
||
|
||
<PopoverBody
|
||
time={null} dur={dur}
|
||
advDone={adv.done} advTotal={adv.total}
|
||
sections={sections} artist={artist}
|
||
statusKey={item.status} onChangeStatus={onChangeStatus}
|
||
/>
|
||
|
||
<div className="cw-pop-ft cw-pop-ft-stack">
|
||
<button className="cw-btn cw-btn-primary cw-btn-block"
|
||
onClick={() => onOpenDetailPage(artist)}>
|
||
Open detailpagina →
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return { Popover, QueuePopover };
|
||
})();
|
||
|
||
Object.assign(window, { CrewliPopover: PV });
|