449 lines
20 KiB
JavaScript
449 lines
20 KiB
JavaScript
// 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 (
|
||
<div className="cw-app">
|
||
<main className="cw-main">
|
||
<Header
|
||
dayCounts={stageCountByDay}
|
||
conflicts={conflictIds.size}
|
||
activeDayId={activeDayId}
|
||
onChangeDay={setActiveDayId}
|
||
onOpenMatrix={() => setMatrixOpen(true)}
|
||
onAddStage={handleAddStage}
|
||
onAddPerformance={() => setPerfModal({
|
||
mode: "add",
|
||
// No stage/time = lands in wachtrij
|
||
perf: { status: "requested" }
|
||
})}
|
||
/>
|
||
|
||
<div className="cw-tt-wrap">
|
||
<TT.Timetable
|
||
dispatch={(action) => {
|
||
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}
|
||
/>
|
||
</div>
|
||
|
||
<FooterToolbar
|
||
stagesActive={dayStages.length}
|
||
performances={dayPerformances.length}
|
||
conflicts={conflictIds.size}
|
||
/>
|
||
</main>
|
||
|
||
{/* 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 (
|
||
<P.Popover
|
||
perf={popoverPerf} artist={artist} stage={stage}
|
||
anchorRect={popover.rect} sections={D.ADVANCE_SECTIONS}
|
||
onChangeStatus={(k) => 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 (
|
||
<P.QueuePopover
|
||
item={it} artist={artist}
|
||
anchorRect={popover.rect} sections={D.ADVANCE_SECTIONS}
|
||
onChangeStatus={(k) => {
|
||
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 && (
|
||
<M.PerformanceModal
|
||
mode={perfModal.mode} perf={perfModal.perf}
|
||
day={activeDay} stages={dayStages.length ? dayStages : stages}
|
||
artists={artists}
|
||
onSave={({ newArtist, perf }) => {
|
||
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 && (
|
||
<M.StageEditor
|
||
stage={stageModal} days={D.EVENT.days} stageDays={stageDays}
|
||
mode={stageModal.__draft ? "create" : "edit"}
|
||
defaultDayIds={stageModal.__draft ? [activeDayId] : null}
|
||
onSave={(s, dayIds) => {
|
||
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 && (
|
||
<M.LineupMatrix
|
||
stages={stages} days={D.EVENT.days} stageDays={stageDays}
|
||
onSave={(list) => { dispatch({ type: "set_stage_days_all", list }); setMatrixOpen(false); }}
|
||
onClose={() => setMatrixOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
{/* Tweaks */}
|
||
<TweaksPanel>
|
||
<TweakSection label="Weergave" />
|
||
<TweakRadio label="Dichtheid" value={t.density}
|
||
options={["compact", "regular", "comfy"]}
|
||
onChange={(v) => setTweak("density", v)} />
|
||
<TweakSlider label="Zoom" value={t.zoom} min={0.5} max={2.5} step={0.1} unit="×"
|
||
onChange={(v) => setTweak("zoom", v)} />
|
||
<TweakSection label="Block details" />
|
||
<TweakToggle label="Advancing %" value={t.showPercent} onChange={(v) => setTweak("showPercent", v)} />
|
||
</TweaksPanel>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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: <path d="M2 7 L8 2 L14 7 V13 H10 V9 H6 V13 H2 Z" />,
|
||
cal: <g><rect x="2.5" y="3.5" width="11" height="9.5" rx="1.5"/><path d="M2.5 6.5 H13.5"/><path d="M5 2 V5 M11 2 V5"/></g>,
|
||
build: <g><rect x="3" y="2.5" width="10" height="11" rx="1"/><path d="M6 5 H7 M9 5 H10 M6 8 H7 M9 8 H10 M6 11 H7 M9 11 H10"/></g>,
|
||
users: <g><circle cx="8" cy="6" r="2.2"/><path d="M3.5 13 C 3.5 10 6 9 8 9 C 10 9 12.5 10 12.5 13"/></g>,
|
||
biz: <g><rect x="2.5" y="4.5" width="11" height="9" rx="1"/><path d="M6 4.5 V3 H10 V4.5"/><path d="M2.5 8.5 H13.5"/></g>,
|
||
alert: <g><path d="M8 2 L14 13 L2 13 Z"/><path d="M8 7 V10 M8 11.5 V11.7"/></g>,
|
||
cog: <g><circle cx="8" cy="8" r="2"/><path d="M8 2 V4 M8 12 V14 M2 8 H4 M12 8 H14 M3.8 3.8 L5.2 5.2 M10.8 10.8 L12.2 12.2 M3.8 12.2 L5.2 10.8 M10.8 5.2 L12.2 3.8"/></g>,
|
||
plus: <g><path d="M8 3 V13 M3 8 H13"/></g>,
|
||
edit: <g><path d="M2.5 13.5 V11 L11 2.5 L13.5 5 L5 13.5 Z"/></g>,
|
||
grid: <g><rect x="2.5" y="2.5" width="4" height="4"/><rect x="9.5" y="2.5" width="4" height="4"/><rect x="2.5" y="9.5" width="4" height="4"/><rect x="9.5" y="9.5" width="4" height="4"/></g>,
|
||
bell: <g><path d="M3.5 11 H12.5 C 11 10 11 5.5 8 5.5 C 5 5.5 5 10 3.5 11 Z"/><path d="M6.5 12.5 C 7 13.5 9 13.5 9.5 12.5"/></g>,
|
||
sun: <g><circle cx="8" cy="8" r="2.5"/><path d="M8 2 V3.5 M8 12.5 V14 M2 8 H3.5 M12.5 8 H14 M3.5 3.5 L4.5 4.5 M11.5 11.5 L12.5 12.5 M3.5 12.5 L4.5 11.5 M11.5 4.5 L12.5 3.5"/></g>,
|
||
search:<g><circle cx="7" cy="7" r="4"/><path d="M10 10 L13.5 13.5"/></g>,
|
||
arrow: <g><path d="M9 3 L4 8 L9 13"/></g>,
|
||
share: <g><circle cx="4" cy="8" r="1.5"/><circle cx="12" cy="4" r="1.5"/><circle cx="12" cy="12" r="1.5"/><path d="M5.3 7.2 L10.7 4.7 M5.3 8.8 L10.7 11.3"/></g>,
|
||
chev: <g><path d="M5 6.5 L8 9.5 L11 6.5"/></g>,
|
||
};
|
||
return <svg {...common}>{paths[name] || null}</svg>;
|
||
}
|
||
|
||
// ─── Header ─────────────────────────────────────────────────────────
|
||
function Header({ dayCounts, conflicts, activeDayId, onChangeDay, onOpenMatrix, onAddPerformance, onAddStage }) {
|
||
const D = window.CrewliData;
|
||
return (
|
||
<div className="cw-hdr">
|
||
<div className="cw-hdr-bar">
|
||
<div className="cw-day-tabs" role="tablist">
|
||
{D.EVENT.days.map(d => (
|
||
<button key={d.id}
|
||
role="tab"
|
||
className={"cw-day-tab" + (d.id === activeDayId ? " is-active" : "")}
|
||
onClick={() => onChangeDay(d.id)}>
|
||
<span className="cw-day-tab-day">{d.label}</span>
|
||
<span className="cw-day-tab-meta">{dayCounts[d.id] || 0} stages</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="cw-hdr-meta">
|
||
{conflicts > 0 && (
|
||
<span className="cw-pill cw-pill-warn">
|
||
<span className="cw-pill-dot" style={{ background: "#d63d4b" }}></span>
|
||
{conflicts} {conflicts === 1 ? "conflict" : "conflicten"}
|
||
</span>
|
||
)}
|
||
|
||
</div>
|
||
|
||
<div style={{ flex: 1 }}></div>
|
||
|
||
<button className="cw-btn cw-btn-soft" onClick={onAddPerformance}>
|
||
<Icon name="plus" /> Performance toevoegen
|
||
</button>
|
||
<button className="cw-btn cw-btn-soft" onClick={onAddStage}>
|
||
<Icon name="plus" /> Stage toevoegen
|
||
</button>
|
||
<button className="cw-btn cw-btn-soft" onClick={onOpenMatrix}>
|
||
<Icon name="grid" /> Stages per {D.EVENT.sub_event_label || "dag"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FooterToolbar({ stagesActive, performances, conflicts }) {
|
||
return (
|
||
<div className="cw-foot">
|
||
<div className="cw-foot-stats">
|
||
<span><b>{stagesActive}</b> stages</span>
|
||
<span><b>{performances}</b> performances</span>
|
||
<span><b>{conflicts}</b> conflicten</span>
|
||
</div>
|
||
<div style={{ flex: 1 }}></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Mount
|
||
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|