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

449 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Main 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 />);