// Main app — wires data, state, modals, popover, drawer, tweaks together. const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "zoom": 1, "density": "regular", "showPercent": true }/*EDITMODE-END*/; function App() { const D = window.CrewliData; const H = window.CrewliHelpers; const M = window.CrewliModals; const P = window.CrewliPopover; const TT = window.CrewliTimetable; // ─── Mutable state (local, no backend) ──────────────────────────── const [stages, setStages] = React.useState(D.STAGES); const [stageDays, setStageDays] = React.useState(D.STAGE_DAYS); const [artists, setArtists] = React.useState(D.ARTISTS); const [performances, setPerformances] = React.useState(D.PERFORMANCES); const [parked, setParked] = React.useState(D.PARKED); const [pending, setPending] = React.useState(D.PENDING); const [activeDayId, setActiveDayId] = React.useState(D.EVENT.days[0].id); // ─── UI state ───────────────────────────────────────────────────── const [popover, setPopover] = React.useState(null); // { perf, rect } or { kind: 'queue', queueKey, item, rect } const [drawer, setDrawer] = React.useState(null); // { perf } const [perfModal, setPerfModal] = React.useState(null); // { mode, perf } const [stageModal, setStageModal] = React.useState(null); // stage const [matrixOpen, setMatrixOpen] = React.useState(false); const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const activeDay = D.EVENT.days.find(d => d.id === activeDayId); // Stages active on this day const dayStages = React.useMemo(() => { const ids = new Set(stageDays.filter(sd => sd.day_id === activeDayId).map(sd => sd.stage_id)); return stages.filter(s => ids.has(s.id)); }, [stages, stageDays, activeDayId]); const dayPerformances = performances.filter(p => p.day_id === activeDayId); // Reducer-ish dispatch function dispatch(action) { if (action.type === "update_perf") { setPerformances(prev => prev.map(p => p.id === action.perf.id ? action.perf : p)); } else if (action.type === "delete_perf") { setPerformances(prev => prev.filter(p => p.id !== action.id)); } else if (action.type === "add_perf") { setPerformances(prev => [...prev, action.perf]); } else if (action.type === "update_stage") { setStages(prev => prev.map(s => s.id === action.stage.id ? action.stage : s)); } else if (action.type === "delete_stage") { setStages(prev => prev.filter(s => s.id !== action.id)); setStageDays(prev => prev.filter(sd => sd.stage_id !== action.id)); // Move scheduled performances on this stage to the wachtrij (parked) i.p.v. ze te verwijderen. setPerformances(prev => prev.filter(p => p.stage_id !== action.id)); setParked(prev => [ ...prev, ...performances .filter(p => p.stage_id === action.id) .map(p => ({ ...p, stage_id: null, dur: (p.end - p.start) || 60 })), ]); } else if (action.type === "add_stage") { setStages(prev => [...prev, action.stage]); setStageDays(prev => [...prev, ...action.days.map(did => ({ stage_id: action.stage.id, day_id: did }))]); } else if (action.type === "reorder_stages") { // dayStageIds = the new ordering for the day's stages. // Strategy: keep non-day stages in their relative positions; replace the // dayStages slice with the new order. setStages(prev => { const dayIdSet = new Set(action.dayStageIds); const dayLookup = Object.fromEntries(prev.filter(s => dayIdSet.has(s.id)).map(s => [s.id, s])); const orderedDay = action.dayStageIds.map(id => dayLookup[id]).filter(Boolean); const result = []; let cursor = 0; for (const s of prev) { if (dayIdSet.has(s.id)) { // Replace with the next entry from orderedDay if (cursor < orderedDay.length) result.push(orderedDay[cursor++]); } else { result.push(s); } } return result; }); } else if (action.type === "set_stage_days_for_stage") { setStageDays(prev => [ ...prev.filter(sd => sd.stage_id !== action.stageId), ...action.dayIds.map(did => ({ stage_id: action.stageId, day_id: did })), ]); } else if (action.type === "set_stage_days_all") { setStageDays(action.list); } else if (action.type === "park_perf") { const p = performances.find(x => x.id === action.id); if (!p) return; setPerformances(prev => prev.filter(x => x.id !== action.id)); setParked(prev => [...prev, { ...p, stage_id: null }]); } else if (action.type === "unpark_perf") { const p = parked.find(x => x.id === action.perf.id); if (!p) return; setParked(prev => prev.filter(x => x.id !== action.perf.id)); setPerformances(prev => [...prev, action.perf]); } else if (action.type === "schedule_pending") { setPending(prev => prev.filter(x => x.id !== action.pendingId)); setPerformances(prev => [...prev, action.perf]); } else if (action.type === "delete_pending") { setPending(prev => prev.filter(x => x.id !== action.id)); } else if (action.type === "delete_parked") { setParked(prev => prev.filter(x => x.id !== action.id)); } else if (action.type === "update_pending_status") { // Pending items don't actually carry a status — promoting their status // means moving them into parked (no stage_id) with that status. const pa = pending.find(x => x.id === action.id); if (!pa) return; const newParked = { id: "p_" + Math.random().toString(36).slice(2, 8), artist_id: pa.artist_id, day_id: pa.day_id, stage_id: null, start: null, end: null, status: action.status, }; setPending(prev => prev.filter(x => x.id !== action.id)); setParked(prev => [...prev, newParked]); } else if (action.type === "update_parked_status") { setParked(prev => prev.map(p => p.id === action.id ? { ...p, status: action.status } : p)); } else if (action.type === "add_parked") { setParked(prev => [...prev, action.parked]); } else if (action.type === "add_artist") { setArtists(prev => [...prev, action.artist]); } } // Move parked → performances when used via drag React.useEffect(() => { const movedBack = performances.filter(p => p.stage_id !== null && parked.some(pk => pk.id === p.id)); if (movedBack.length > 0) { setParked(prev => prev.filter(pk => !movedBack.some(m => m.id === pk.id))); } }, [performances]); // Layout numerics const pxPerMin = 4 * t.zoom; const rowHeight = t.density === "compact" ? 52 : t.density === "comfy" ? 84 : 64; // ─── Conflict counts (for header chip) ───────────────────────────── const conflictIds = H.findConflicts(dayPerformances); // Day counts const stageCountByDay = {}; D.EVENT.days.forEach(d => { stageCountByDay[d.id] = stageDays.filter(sd => sd.day_id === d.id).length; }); // ─── Handlers ───────────────────────────────────────────────────── function handleSelectPerf(perf, rect) { setPopover({ kind: "perf", perfId: perf.id, rect }); setDrawer(null); } function handleSelectQueueItem(item, rect) { // item: { kind, id, artist, status, dur, src } setPopover({ kind: "queue", queueKey: item.kind + ":" + item.id, item, rect }); setDrawer(null); } function handleClickEmpty(stage, minute, end, lane) { setPerfModal({ mode: "add", perf: { stage_id: stage.id, start: minute, end: typeof end === "number" ? end : minute + 60, status: "concept", ...(Number.isInteger(lane) ? { lane } : {}), }, }); setPopover(null); } function handleEditStage(stage) { setStageModal(stage); setPopover(null); } function handleAddStage() { // Draft stage — niet meteen toevoegen aan state. StageEditor commit'et bij Opslaan. const id = "s_" + Math.random().toString(36).slice(2, 8); const draft = { id, name: "Nieuwe stage", color: "#3cc2a8", capacity: 2000, __draft: true }; setStageModal(draft); } const popoverPerf = popover && popover.kind === "perf" ? performances.find(p => p.id === popover.perfId) : null; const drawerPerf = drawer ? performances.find(p => p.id === drawer.perfId) : null; return (
setMatrixOpen(true)} onAddStage={handleAddStage} onAddPerformance={() => setPerfModal({ mode: "add", // No stage/time = lands in wachtrij perf: { status: "requested" } })} />
{ if (action.type === "update_perf" && parked.some(pk => pk.id === action.perf.id) && action.perf.stage_id) { dispatch({ type: "unpark_perf", perf: action.perf }); } else { dispatch(action); } }} activeDay={activeDay} stages={dayStages} performances={dayPerformances} parked={parked.filter(p => p.day_id === activeDayId)} pending={pending.filter(p => p.day_id === activeDayId)} artists={artists} onSelectPerf={handleSelectPerf} onClickEmptyCell={handleClickEmpty} onEditStage={handleEditStage} onScheduleFromParking={({ pending: pa, stage, minute, lane, openModal }) => { if (openModal) { setPerfModal({ mode: "add", perf: { artist_id: pa.artist_id, stage_id: stage.id, start: minute, end: minute + 60, status: "requested", lane }, pendingId: pa.id, }); } else { const newPerf = { id: "p_" + Math.random().toString(36).slice(2, 8), artist_id: pa.artist_id, stage_id: stage.id, day_id: pa.day_id, start: minute, end: minute + 60, status: "requested", ...(Number.isInteger(lane) ? { lane } : {}), }; dispatch({ type: "schedule_pending", pendingId: pa.id, perf: newPerf }); } }} pxPerMin={pxPerMin} baseRowHeight={rowHeight} density={t.density} showPercent={t.showPercent} onSelectQueueItem={handleSelectQueueItem} selectedQueueId={popover && popover.kind === "queue" ? popover.queueKey : null} />
{/* Popover — performance */} {popover && popover.kind === "perf" && popoverPerf && (() => { const stage = stages.find(s => s.id === popoverPerf.stage_id); const artist = artists.find(a => a.id === popoverPerf.artist_id); return ( dispatch({ type: "update_perf", perf: { ...popoverPerf, status: k }})} onOpenDetailPage={(a, p) => alert(`Navigatie naar /events/${D.EVENT.id || "ezf_2026"}/artists/${a.id} (out of scope voor deze PoC)`)} onClose={() => setPopover(null)} /> ); })()} {/* Popover — queue item (read-only summary, status switch, navigate to detail) */} {popover && popover.kind === "queue" && (() => { const it = popover.item; const artist = artists.find(a => a.id === it.artist.id); return ( { if (it.kind === "pending") dispatch({ type: "update_pending_status", id: it.id, status: k }); else dispatch({ type: "update_parked_status", id: it.id, status: k }); }} onOpenDetailPage={(a) => alert(`Navigatie naar /events/${D.EVENT.id || "ezf_2026"}/artists/${a.id} (out of scope voor deze PoC)`)} onClose={() => setPopover(null)} /> ); })()} {/* Drawer is no longer used — detail navigation is handled via Open detailpagina */} {/* Modals */} {perfModal && ( { if (perfModal.mode === "add") { if (newArtist) dispatch({ type: "add_artist", artist: newArtist }); if (perf.stage_id) { dispatch({ type: "add_perf", perf }); } else { dispatch({ type: "add_parked", parked: { ...perf, dur: perf.dur || 60 } }); } } else { dispatch({ type: "update_perf", perf }); } setPerfModal(null); }} onDelete={(id) => { dispatch({ type: "delete_perf", id }); setPerfModal(null); }} onClose={() => setPerfModal(null)} /> )} {stageModal && ( { const { __draft, ...clean } = s; if (stageModal.__draft) { dispatch({ type: "add_stage", stage: clean, days: dayIds }); } else { dispatch({ type: "update_stage", stage: clean }); dispatch({ type: "set_stage_days_for_stage", stageId: clean.id, dayIds }); } setStageModal(null); }} onDelete={(id) => { const affected = performances.filter(p => p.stage_id === id).length; const stageName = stages.find(s => s.id === id)?.name || "deze stage"; const msg = affected > 0 ? `Weet je zeker dat je "${stageName}" wilt verwijderen?\n\n${affected} ingeplande act${affected === 1 ? "" : "s"} ${affected === 1 ? "wordt" : "worden"} naar de wachtrij verplaatst (niet verwijderd).` : `Weet je zeker dat je "${stageName}" wilt verwijderen?`; if (!window.confirm(msg)) return; dispatch({ type: "delete_stage", id }); setStageModal(null); }} onClose={() => setStageModal(null)} /> )} {matrixOpen && ( { dispatch({ type: "set_stage_days_all", list }); setMatrixOpen(false); }} onClose={() => setMatrixOpen(false)} /> )} {/* Tweaks */} setTweak("density", v)} /> setTweak("zoom", v)} /> setTweak("showPercent", v)} />
); } // minimal inline icons function Icon({ name }) { const common = { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round", strokeLinejoin: "round" }; const paths = { home: , cal: , build: , users: , biz: , alert: , cog: , plus: , edit: , grid: , bell: , sun: , search:, arrow: , share: , chev: , }; return {paths[name] || null}; } // ─── Header ───────────────────────────────────────────────────────── function Header({ dayCounts, conflicts, activeDayId, onChangeDay, onOpenMatrix, onAddPerformance, onAddStage }) { const D = window.CrewliData; return (
{D.EVENT.days.map(d => ( ))}
{conflicts > 0 && ( {conflicts} {conflicts === 1 ? "conflict" : "conflicten"} )}
); } function FooterToolbar({ stagesActive, performances, conflicts }) { return (
{stagesActive} stages {performances} performances {conflicts} conflicten
); } // Mount ReactDOM.createRoot(document.getElementById("root")).render();