docs/rfc-timetable-v0.2-foundation #14
@@ -19,3 +19,4 @@ dev-docs/ARCH-OBSERVABILITY.md
|
||||
dev-docs/runbooks/observability-triage.md
|
||||
dev-docs/runbooks/observability-erasure.md
|
||||
dev-docs/RFC-WS-6.md
|
||||
dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md
|
||||
1664
dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md
Normal file
1664
dev-docs/RFC-TIMETABLE-Artist-Timetable-Module.md
Normal file
File diff suppressed because it is too large
Load Diff
1293
dev-docs/audits/PROTOTYPE-AUDIT-ARTIST-TIMETABLE.md
Normal file
1293
dev-docs/audits/PROTOTYPE-AUDIT-ARTIST-TIMETABLE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Crewli · Timetable</title>
|
||||
<meta name="viewport" content="width=1280" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script src="data.js"></script>
|
||||
<script src="helpers.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
||||
<script type="text/babel" src="modals.jsx"></script>
|
||||
<script type="text/babel" src="popover.jsx"></script>
|
||||
<script type="text/babel" src="timetable.jsx"></script>
|
||||
<script type="text/babel" src="app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
517
resources/Crewli - Artist Timetable Management/app.jsx
Normal file
517
resources/Crewli - Artist Timetable Management/app.jsx
Normal file
@@ -0,0 +1,517 @@
|
||||
// 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">
|
||||
<Sidebar />
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sidebar (matching Crewli look) ─────────────────────────────────
|
||||
function Sidebar() {
|
||||
const items = [
|
||||
{ icon: "home", label: "Dashboard" },
|
||||
{ icon: "cal", label: "Evenementen", active: true },
|
||||
];
|
||||
const orgItems = [
|
||||
{ icon: "build", label: "Mijn Organisatie" },
|
||||
{ icon: "users", label: "Leden" },
|
||||
{ icon: "biz", label: "Bedrijven" },
|
||||
{ icon: "alert", label: "Form failures" },
|
||||
{ icon: "cog", label: "Instellingen" },
|
||||
];
|
||||
return (
|
||||
<aside className="cw-side">
|
||||
<div className="cw-side-brand">
|
||||
<span className="cw-side-logo">c</span>
|
||||
<span className="cw-side-brand-name">Crewli</span>
|
||||
<span className="cw-side-brand-dot"></span>
|
||||
</div>
|
||||
<div className="cw-side-org">
|
||||
<span className="cw-side-org-icon">▥</span>
|
||||
<span className="cw-side-org-name">Stichting Feestfab…</span>
|
||||
</div>
|
||||
<nav className="cw-side-nav">
|
||||
{items.map(it => (
|
||||
<a key={it.label} className={"cw-side-item" + (it.active ? " is-active" : "")}>
|
||||
<Icon name={it.icon} /> {it.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<div className="cw-side-section">Stichting Feestfabriek</div>
|
||||
<nav className="cw-side-nav">
|
||||
{orgItems.map(it => (
|
||||
<a key={it.label} className="cw-side-item">
|
||||
<Icon name={it.icon} /> {it.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
// 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-top">
|
||||
<button className="cw-hdr-back"><Icon name="arrow" /></button>
|
||||
<div>
|
||||
<div className="cw-hdr-title">
|
||||
{D.EVENT.name}
|
||||
<span className="cw-hdr-pill">Festival</span>
|
||||
</div>
|
||||
<div className="cw-hdr-sub">10-07-2026 – 11-07-2026 · Timetable</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<button className="cw-btn"><Icon name="share" /> Delen</button>
|
||||
<button className="cw-btn"><Icon name="chev" /> Registratie open</button>
|
||||
</div>
|
||||
|
||||
<div className="cw-hdr-tabs">
|
||||
<button className="cw-tab">Overzicht</button>
|
||||
<button className="cw-tab">Programmaonderdelen</button>
|
||||
<button className="cw-tab">Tijdsloten</button>
|
||||
<button className="cw-tab">Secties & Shifts</button>
|
||||
<button className="cw-tab">Personen</button>
|
||||
<button className="cw-tab">Artiesten</button>
|
||||
<button className="cw-tab is-active">Timetable</button>
|
||||
</div>
|
||||
|
||||
<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 />);
|
||||
159
resources/Crewli - Artist Timetable Management/data.js
Normal file
159
resources/Crewli - Artist Timetable Management/data.js
Normal file
@@ -0,0 +1,159 @@
|
||||
// Echt Zomer Feesten 2026 - demo data
|
||||
// Schema mirrors Crewli §3.5.7: stages, stage_days, artists, performances
|
||||
|
||||
window.CrewliData = (function () {
|
||||
const EVENT = {
|
||||
id: "ezf_2026",
|
||||
name: "Echt Zomer Feesten",
|
||||
edition: "2026",
|
||||
sub_event_label: "dag", // configurable in Crewli — could be "dag", "deelevent", "fase", etc.
|
||||
days: [
|
||||
{ id: "d_fr", date: "2026-07-10", label: "Vrijdag", short: "Vr" },
|
||||
{ id: "d_sa", date: "2026-07-11", label: "Zaterdag", short: "Za" },
|
||||
],
|
||||
};
|
||||
|
||||
// Stages — physical platforms. color is the stage swatch (used as left-band on row)
|
||||
const STAGES = [
|
||||
{ id: "s_hardstyle", name: "Hardstyle District", color: "#e85d75", capacity: 4500 },
|
||||
{ id: "s_techno", name: "Techno × House", color: "#7a8af0", capacity: 3000 },
|
||||
{ id: "s_hollandse", name: "Hollandse Hoek", color: "#f0a04b", capacity: 5000 },
|
||||
{ id: "s_urban", name: "Echt Urban", color: "#5fc9a8", capacity: 2800 },
|
||||
{ id: "s_silent", name: "Silent Disco", color: "#c89af0", capacity: 800 },
|
||||
{ id: "s_schirm", name: "Schirmbar", color: "#e8d05f", capacity: 600 },
|
||||
];
|
||||
|
||||
// stage_days pivot: which stages run on which day
|
||||
const STAGE_DAYS = [
|
||||
// Vrijdag
|
||||
{ stage_id: "s_hardstyle", day_id: "d_fr" },
|
||||
{ stage_id: "s_techno", day_id: "d_fr" },
|
||||
{ stage_id: "s_silent", day_id: "d_fr" },
|
||||
{ stage_id: "s_schirm", day_id: "d_fr" },
|
||||
// Zaterdag
|
||||
{ stage_id: "s_hollandse", day_id: "d_sa" },
|
||||
{ stage_id: "s_urban", day_id: "d_sa" },
|
||||
{ stage_id: "s_silent", day_id: "d_sa" },
|
||||
{ stage_id: "s_schirm", day_id: "d_sa" },
|
||||
];
|
||||
|
||||
// Advance sections (per artist, configurable in real schema; here we use a
|
||||
// pragmatic standard set so the popover dots have meaning)
|
||||
const ADVANCE_SECTIONS = [
|
||||
{ key: "tour", label: "Tourmanager" },
|
||||
{ key: "hosp", label: "Hospitality" },
|
||||
{ key: "travel", label: "Travel party" },
|
||||
{ key: "flight", label: "Flight" },
|
||||
{ key: "rider", label: "Tech rider" },
|
||||
];
|
||||
|
||||
// Artists. `draw` = expected pull (for capacity warnings). `advance` = which
|
||||
// sections are completed (just demo state for the popover).
|
||||
// Genres (closed list — drives filters in the wachtrij and the small label on blocks)
|
||||
const GENRES = ["Hardstyle", "Techno", "House", "Hollands", "Pop", "Urban", "Disco", "Aprés"];
|
||||
|
||||
const ARTISTS = [
|
||||
{ id: "a_1", name: "D-Block & S-te-Fan", initials: "DS", genre: "Hardstyle", draw: 4200, advance: { tour: true, hosp: true, travel: true, flight: false, rider: true } },
|
||||
{ id: "a_2", name: "Sub Zero Project", initials: "SZ", genre: "Hardstyle", draw: 3800, advance: { tour: true, hosp: true, travel: false, flight: false, rider: true } },
|
||||
{ id: "a_3", name: "Warface", initials: "WF", genre: "Hardstyle", draw: 3000, advance: { tour: true, hosp: false, travel: false, flight: false, rider: false } },
|
||||
{ id: "a_4", name: "Devin Wild", initials: "DW", genre: "Hardstyle", draw: 2400, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } },
|
||||
|
||||
{ id: "a_5", name: "Reinier Zonneveld", initials: "RZ", genre: "Techno", draw: 2800, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } },
|
||||
{ id: "a_6", name: "Boris Brejcha", initials: "BB", genre: "Techno", draw: 3200, advance: { tour: true, hosp: true, travel: false, flight: true, rider: true } },
|
||||
{ id: "a_7", name: "Mau P", initials: "MP", genre: "House", draw: 2200, advance: { tour: true, hosp: false, travel: false, flight: false, rider: true } },
|
||||
|
||||
{ id: "a_8", name: "Snollebollekes", initials: "SB", genre: "Hollands", draw: 5200, advance: { tour: true, hosp: true, travel: true, flight: false, rider: true } },
|
||||
{ id: "a_9", name: "Mart Hoogkamer", initials: "MH", genre: "Hollands", draw: 3600, advance: { tour: true, hosp: true, travel: false, flight: false, rider: true } },
|
||||
{ id: "a_10", name: "Suzan & Freek", initials: "SF", genre: "Pop", draw: 4800, advance: { tour: false, hosp: false, travel: false, flight: false, rider: false } },
|
||||
{ id: "a_11", name: "Frans Duijts", initials: "FD", genre: "Hollands", draw: 2600, advance: { tour: true, hosp: true, travel: true, flight: false, rider: true } },
|
||||
|
||||
{ id: "a_12", name: "Bizzey", initials: "BZ", genre: "Urban", draw: 2400, advance: { tour: true, hosp: true, travel: true, flight: false, rider: true } },
|
||||
{ id: "a_13", name: "Frenna", initials: "FN", genre: "Urban", draw: 2800, advance: { tour: true, hosp: true, travel: false, flight: false, rider: false } },
|
||||
{ id: "a_14", name: "Kris Kross Amsterdam", initials: "KK", genre: "Pop", draw: 2200, advance: { tour: true, hosp: false, travel: false, flight: false, rider: true } },
|
||||
|
||||
{ id: "a_15", name: "DJ Marlon", initials: "DM", genre: "Disco", draw: 600, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } },
|
||||
{ id: "a_16", name: "DJ Senna", initials: "DS", genre: "Disco", draw: 500, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } },
|
||||
|
||||
{ id: "a_17", name: "Café Catootje", initials: "CC", genre: "Aprés", draw: 400, advance: { tour: true, hosp: true, travel: true, flight: true, rider: true } },
|
||||
{ id: "a_18", name: "Apré Heroes", initials: "AH", genre: "Aprés", draw: 350, advance: { tour: false, hosp: true, travel: false, flight: true, rider: false } },
|
||||
];
|
||||
|
||||
// Performances — booking_status: concept | requested | option | confirmed | contracted | cancelled
|
||||
// Times are minutes since the day's anchor (14:00). End times are minute offsets too.
|
||||
// To keep math simple we use the same minute-grid for both days.
|
||||
const PERFORMANCES = [
|
||||
// ─── Vrijdag · Hardstyle District (s_hardstyle, d_fr) ─────────────
|
||||
{ id: "p_1", artist_id: "a_3", stage_id: "s_hardstyle", day_id: "d_fr", start: 240, end: 360, status: "confirmed" }, // 18:00-20:00
|
||||
{ id: "p_2", artist_id: "a_4", stage_id: "s_hardstyle", day_id: "d_fr", start: 360, end: 480, status: "confirmed" }, // 20:00-22:00
|
||||
{ id: "p_3", artist_id: "a_2", stage_id: "s_hardstyle", day_id: "d_fr", start: 480, end: 600, status: "contracted" }, // 22:00-00:00
|
||||
// Concept-fase: 3 parallelle aanvragen voor de 00:00-02:00 slot — pas één wordt vastgelegd
|
||||
{ id: "p_4", artist_id: "a_1", stage_id: "s_hardstyle", day_id: "d_fr", start: 600, end: 720, status: "option" }, // 00:00-02:00
|
||||
{ id: "p_4b", artist_id: "a_2", stage_id: "s_hardstyle", day_id: "d_fr", start: 600, end: 720, status: "requested" }, // 00:00-02:00 (parallel aanvraag)
|
||||
{ id: "p_4c", artist_id: "a_4", stage_id: "s_hardstyle", day_id: "d_fr", start: 615, end: 705, status: "concept" }, // 00:15-01:45 (alternatief concept)
|
||||
|
||||
// ─── Vrijdag · Techno × House (s_techno, d_fr) ────────────────────
|
||||
{ id: "p_5", artist_id: "a_7", stage_id: "s_techno", day_id: "d_fr", start: 300, end: 420, status: "confirmed" }, // 19:00-21:00
|
||||
{ id: "p_6", artist_id: "a_5", stage_id: "s_techno", day_id: "d_fr", start: 420, end: 555, status: "contracted" }, // 21:00-23:15
|
||||
{ id: "p_7", artist_id: "a_6", stage_id: "s_techno", day_id: "d_fr", start: 555, end: 690, status: "requested" }, // 23:15-01:30
|
||||
|
||||
// ─── Vrijdag · Silent Disco (s_silent, d_fr) ──────────────────────
|
||||
{ id: "p_8", artist_id: "a_15", stage_id: "s_silent", day_id: "d_fr", start: 360, end: 540, status: "confirmed" }, // 20:00-23:00
|
||||
{ id: "p_9", artist_id: "a_16", stage_id: "s_silent", day_id: "d_fr", start: 540, end: 720, status: "confirmed" }, // 23:00-02:00
|
||||
|
||||
// ─── Vrijdag · Schirmbar (s_schirm, d_fr) ─────────────────────────
|
||||
{ id: "p_10", artist_id: "a_17", stage_id: "s_schirm", day_id: "d_fr", start: 180, end: 360, status: "confirmed" }, // 17:00-20:00
|
||||
{ id: "p_11", artist_id: "a_18", stage_id: "s_schirm", day_id: "d_fr", start: 360, end: 540, status: "concept" }, // 20:00-23:00
|
||||
|
||||
// ─── Zaterdag · Hollandse Hoek (s_hollandse, d_sa) ────────────────
|
||||
{ id: "p_12", artist_id: "a_11", stage_id: "s_hollandse", day_id: "d_sa", start: 240, end: 345, status: "confirmed" }, // 18:00-19:45
|
||||
{ id: "p_13", artist_id: "a_9", stage_id: "s_hollandse", day_id: "d_sa", start: 345, end: 465, status: "confirmed" }, // 19:45-21:45
|
||||
{ id: "p_14", artist_id: "a_10", stage_id: "s_hollandse", day_id: "d_sa", start: 465, end: 600, status: "option" }, // 21:45-00:00 (cap warning - draw 4800 vs 5000)
|
||||
{ id: "p_15", artist_id: "a_8", stage_id: "s_hollandse", day_id: "d_sa", start: 600, end: 720, status: "contracted" }, // 00:00-02:00 (cap warning — draw 5200 > 5000)
|
||||
|
||||
// ─── Zaterdag · Echt Urban (s_urban, d_sa) ────────────────────────
|
||||
{ id: "p_16", artist_id: "a_14", stage_id: "s_urban", day_id: "d_sa", start: 300, end: 420, status: "confirmed" }, // 19:00-21:00
|
||||
{ id: "p_17", artist_id: "a_13", stage_id: "s_urban", day_id: "d_sa", start: 420, end: 540, status: "confirmed" }, // 21:00-23:00
|
||||
{ id: "p_18", artist_id: "a_12", stage_id: "s_urban", day_id: "d_sa", start: 540, end: 660, status: "requested" }, // 23:00-01:00
|
||||
|
||||
// Demo conflict: two performances overlapping on Echt Urban
|
||||
{ id: "p_19", artist_id: "a_7", stage_id: "s_urban", day_id: "d_sa", start: 510, end: 600, status: "concept" }, // 22:30-00:00 overlap with Frenna
|
||||
|
||||
// ─── Zaterdag · Silent Disco (s_silent, d_sa) ─────────────────────
|
||||
{ id: "p_20", artist_id: "a_16", stage_id: "s_silent", day_id: "d_sa", start: 360, end: 540, status: "confirmed" },
|
||||
{ id: "p_21", artist_id: "a_15", stage_id: "s_silent", day_id: "d_sa", start: 540, end: 720, status: "confirmed" },
|
||||
|
||||
// ─── Zaterdag · Schirmbar (s_schirm, d_sa) ────────────────────────
|
||||
{ id: "p_22", artist_id: "a_18", stage_id: "s_schirm", day_id: "d_sa", start: 180, end: 360, status: "concept" },
|
||||
{ id: "p_23", artist_id: "a_17", stage_id: "s_schirm", day_id: "d_sa", start: 360, end: 540, status: "confirmed" },
|
||||
];
|
||||
|
||||
// ─── Parked performances (stage_id === null) ─────────────────────
|
||||
// Artists picked up but not yet placed on a stage. Day-id keeps day-context.
|
||||
const PARKED = [
|
||||
{ id: "pk_1", artist_id: "a_5", stage_id: null, day_id: "d_fr", start: 480, end: 600, status: "option" },
|
||||
{ id: "pk_2", artist_id: "a_13", stage_id: null, day_id: "d_sa", start: 540, end: 660, status: "requested" },
|
||||
{ id: "pk_3", artist_id: "a_7", stage_id: null, day_id: "d_fr", start: 420, end: 540, status: "concept" },
|
||||
{ id: "pk_4", artist_id: "a_9", stage_id: null, day_id: "d_sa", start: 360, end: 450, status: "confirmed" },
|
||||
{ id: "pk_5", artist_id: "a_11", stage_id: null, day_id: "d_fr", start: 600, end: 690, status: "contracted" },
|
||||
];
|
||||
|
||||
// ─── Pending availability requests ───────────────────────────────
|
||||
// Artists waarvoor beschikbaarheid is opgevraagd maar nog geen tijdvak gekozen.
|
||||
const PENDING = [
|
||||
{ id: "pa_1", artist_id: "a_6", day_id: "d_fr", requested_on: "2026-04-12", note: "Wachten op terugkoppeling agent" },
|
||||
{ id: "pa_2", artist_id: "a_8", day_id: "d_sa", requested_on: "2026-04-08", note: "Beschikbaar — wacht op fee-onderhandeling" },
|
||||
{ id: "pa_3", artist_id: "a_12", day_id: "d_sa", requested_on: "2026-04-15", note: "Boeking via Top Notch" },
|
||||
{ id: "pa_4", artist_id: "a_10", day_id: "d_fr", requested_on: "2026-04-18", note: "Optie tot 30 april" },
|
||||
];
|
||||
|
||||
// Time grid: 14:00 -> 03:00 next day = 13 hours = 780 minutes
|
||||
// start=0 means 14:00, start=600 means 00:00, start=780 means 03:00
|
||||
const TIME = {
|
||||
startHour: 14, // grid starts at 14:00
|
||||
totalMinutes: 780, // 13 hours
|
||||
snapMinutes: 15, // drag snap
|
||||
cellMinutes: 30, // grid cell width
|
||||
};
|
||||
|
||||
return { EVENT, STAGES, STAGE_DAYS, ADVANCE_SECTIONS, ARTISTS, GENRES, PERFORMANCES, PARKED, PENDING, TIME };
|
||||
})();
|
||||
@@ -0,0 +1,530 @@
|
||||
# RFC-TIMETABLE — Artist Timetable Module
|
||||
|
||||
## 1. Status
|
||||
|
||||
- **State:** Draft for review
|
||||
- **Created:** 2026-05-08
|
||||
- **Version:** v0.1
|
||||
- **Owner:** Bert Hausmans
|
||||
- **Origin:** UX brainstorm session 2026-05-08 (Claude Chat) — concept + three PoC iterations + six locked decisions
|
||||
- **Related:**
|
||||
- `SCHEMA.md` §3.5.7 (artists, performances, stages, stage_days, advance_sections)
|
||||
- `BACKLOG.md` ARCH-09 (Artist model — hard prerequisite), ART-02 (Timetable backlog item)
|
||||
- `ARCH-FORM-BUILDER.md` §17 (artist_advance purpose subject_type)
|
||||
- `CLAUDE.md` "Order of work" (17-step module-generation sequence)
|
||||
- `design-document.md` §3.5.10 Database design rules
|
||||
- `dev-docs/PurposeRegistry` — artist_advance purpose with subject_type=artist
|
||||
|
||||
## 2. Why this RFC exists
|
||||
|
||||
The Artist Timetable is the central planning surface for festival programming.
|
||||
It is operationally critical: production managers spend hours per week on it,
|
||||
last-minute changes are common, and a bug here visibly breaks the show. Three
|
||||
properties make this module non-trivial enough to warrant up-front spec:
|
||||
|
||||
1. It depends on a model (Artist) that does not exist yet — see ARCH-09.
|
||||
2. It bridges three concerns that festival operators conflate but Crewli must
|
||||
keep distinct: stages (physical), festival_sections (organisational), and
|
||||
advancing (per-section workflow).
|
||||
3. The interaction surface (drag-drop Gantt with conflict detection,
|
||||
cross-day stage variance, popover with advancing summary) does not exist
|
||||
off-the-shelf in the current frontend stack — the build-vs-buy choice for
|
||||
the rendering library has multi-week consequences.
|
||||
|
||||
This RFC captures every architectural decision so implementation does not
|
||||
re-litigate them under time pressure. PoC iterations (chat artefacts v1/v2/v3)
|
||||
are visual references, not authoritative — this document is.
|
||||
|
||||
## 3. Scope & non-scope
|
||||
|
||||
### In scope (v1)
|
||||
|
||||
- CRUD for stages and stage_days (which stage runs which day)
|
||||
- CRUD for performances (artist on stage at time)
|
||||
- Horizontal Gantt timetable: stages as rows, time as x-axis, day tabs
|
||||
- Drag/drop performance: re-time and re-stage in one gesture
|
||||
- Resize performance duration (snap to 15 min)
|
||||
- Click-to-add performance from empty grid cell
|
||||
- Detail popover with avatar, status pill, advancing aggregate, status switch,
|
||||
delete, manage-booking link
|
||||
- Conflict detection: same-stage same-day overlapping non-cancelled performances
|
||||
- Back-to-back marker: ≤5 min gap between consecutive performances on same stage
|
||||
- "Edit lineup" matrix: bulk toggle stages × days
|
||||
- Empty-day state: copy-from-other-day affordance
|
||||
- Activity log on all mutations
|
||||
- Multi-tenancy via OrganisationScope (FK-chain through event)
|
||||
|
||||
### Out of scope (v1)
|
||||
|
||||
- **Artist Handling / show-day check-in view** — separate Mission Control module
|
||||
- **Capacity warning** on performance blocks — requires a new column
|
||||
(`performances.expected_attendance` or similar). Defer to v2; backlog as
|
||||
ART-04.
|
||||
- **Advance section CRUD** — covered by separate Artist Advancing module
|
||||
(BACKLOG ART-01). The popover only *reads* advance_sections aggregate.
|
||||
- **PDF / print export** of running order — backlog as ART-05.
|
||||
- **Multi-select bulk shift** of performances ("move everything on Mainstage
|
||||
+15 min") — backlog as ART-06.
|
||||
- **Undo/redo stack** — backlog as ART-07.
|
||||
- **Mobile-optimised view** — timetable is a desktop tool. Mobile gets a
|
||||
read-only list view in a later iteration.
|
||||
- **Stage templates across events** ("copy stages from last year's event")
|
||||
— backlog as ARCH-10, parallel to ARCH-03 (festival_section templates).
|
||||
|
||||
## 4. Locked design decisions
|
||||
|
||||
### D1 — Block visual: stage-stripe + status-fill
|
||||
|
||||
Each performance block carries:
|
||||
|
||||
- Background fill = booking_status colour (6 colours, see §10).
|
||||
- 3px left stripe = `stages.color` for stage-grouping cue.
|
||||
- Right grab handle for resize.
|
||||
|
||||
Rationale: status-fill alone (PoC v2) lost stage grouping when many stages
|
||||
shared similar status. The stripe restores the visual stage-cluster signal at
|
||||
zero cost. (Bert decision 2026-05-08.)
|
||||
|
||||
### D2 — Advancing aggregate, not per-section dots
|
||||
|
||||
The detail popover shows `n/m completed` (e.g. "3/5 advancing complete") and
|
||||
the underlying section list as a tooltip on hover. No per-section dots on the
|
||||
block itself.
|
||||
|
||||
Rationale: `advance_sections` is configurable per artist — fixed 5-dot icons
|
||||
break the moment one artist has 4 sections and another has 7. Aggregate
|
||||
fraction degrades gracefully across configurations and reads cleanly at small
|
||||
sizes. (Bert decision 2026-05-08.)
|
||||
|
||||
The aggregate is computed as:
|
||||
|
||||
```
|
||||
n = count(advance_sections WHERE artist_id = X AND submission_status = 'accepted')
|
||||
m = count(advance_sections WHERE artist_id = X)
|
||||
```
|
||||
|
||||
Implementation: cached on `artists` model as a denormalised
|
||||
`advancing_completed_count` + `advancing_total_count` pair, recomputed via
|
||||
observer on `advance_section` create/update/delete. Avoids N+1 on timetable
|
||||
load. To be added in same migration as Artist model (ARCH-09).
|
||||
|
||||
### D3 — Manage booking opens artist page (not modal)
|
||||
|
||||
Click "Manage booking" in popover → navigate to
|
||||
`/events/{event}/artists/{artist}?return=timetable&day=fri&t=210000`. The
|
||||
artist page surfaces all related records (performances, advance_sections,
|
||||
contacts, riders, itinerary) as tabs. A prominent return banner at top:
|
||||
"← Back to Timetable (Vr 21:00)" restores scroll/tab context.
|
||||
|
||||
Rationale: artist record is too large for modal (5+ relation tables). Page
|
||||
navigation matches every other Crewli edit surface (shifts, persons,
|
||||
festival_sections). Bookmarkability + cmd-click-to-new-tab are bonuses
|
||||
no modal can provide. (Bert decision 2026-05-08.)
|
||||
|
||||
### D4 — Stage-day filtering is enforced everywhere
|
||||
|
||||
The timetable for day D shows only stages where `stage_days(stage_id, D)`
|
||||
exists. Performances scheduled on `(stage_id, D)` where `stage_days(...)` does
|
||||
not exist are **hidden but not deleted** — toggling a stage off a day must be
|
||||
reversible without data loss.
|
||||
|
||||
This is enforced at:
|
||||
|
||||
- API list endpoint (filter at SQL level)
|
||||
- Frontend rendering (defensive — trust API but verify)
|
||||
- Performance creation — the FormRequest validates that `stage_days`
|
||||
exists for the requested `(stage_id, performance.date)` and rejects
|
||||
with 422 otherwise.
|
||||
|
||||
### D5 — Conflict detection scope: same-stage same-day overlap only
|
||||
|
||||
Cross-stage overlap (same artist on two stages simultaneously) is **not**
|
||||
flagged in v1. Reason: an artist on two stages at once is rare but legitimate
|
||||
at small festivals where one DJ runs main set + silent disco simultaneously.
|
||||
Surface this on the artist page in a future iteration, not on the timetable.
|
||||
|
||||
Cancelled performances participate in **no** conflict check.
|
||||
|
||||
### D6 — B2B marker rule
|
||||
|
||||
Two consecutive non-cancelled performances on the same stage with
|
||||
`p2.start - p1.end ∈ [0, 5]` minutes get a B2B marker (small dot at the
|
||||
boundary). Useful for stage managers planning changeover windows.
|
||||
|
||||
Pure rendering concern — no schema impact.
|
||||
|
||||
### D7 — Drag-drop interaction model
|
||||
|
||||
- Drag block horizontally → re-time (snap to 15 min)
|
||||
- Drag block vertically → re-stage (only across stages active on current day)
|
||||
- Drag right edge → resize duration (snap to 15 min, min 15 min)
|
||||
- Drag is committed on mouseup with single PATCH request
|
||||
- Failed PATCH (validation error, conflict the user wants to refuse) reverts
|
||||
block to its origin position with a toast
|
||||
|
||||
### D8 — Rendering library: custom Vue components, not FullCalendar
|
||||
|
||||
**Recommendation: build custom.**
|
||||
|
||||
FullCalendar timeline view (`resourceTimelinePlugin`) was the obvious choice
|
||||
on paper but has three blockers:
|
||||
|
||||
1. Premium-licensed (~€600/yr commercial). Acceptable cost but adds
|
||||
procurement friction and a vendor dependency.
|
||||
2. The advancing-aggregate badge, stage-stripe, B2B marker, and conflict
|
||||
ring all require `eventDidMount` render hooks — at that point we're
|
||||
reimplementing rendering inside FC's render cycle, fighting its DOM.
|
||||
3. Cross-day stage filtering (D4) does not map cleanly to FC's resource
|
||||
model. Workaround is per-day resource list rebuild on tab switch, which
|
||||
defeats incremental-render benefits.
|
||||
|
||||
Custom Vue components (TimetableGrid, StageRow, PerformanceBlock,
|
||||
PerformancePopover, LineupMatrix) on top of native HTML5 drag-and-drop give
|
||||
total control at ~3 days additional frontend cost. The PoC v3 is already
|
||||
~80% of the rendering logic. PoC code must be rewritten as proper Vue 3
|
||||
components (Composition API, `<script setup lang="ts">`) — the PoC is not
|
||||
production code.
|
||||
|
||||
**Open verification before commit:** test drag-and-drop accessibility with
|
||||
screen reader. If insurmountable, revisit FullCalendar Premium decision.
|
||||
|
||||
### D9 — `performances.booking_status` migrates from string to PHP Enum
|
||||
|
||||
SCHEMA §3.5.7 currently lists `booking_status` on performances as `string`.
|
||||
This violates the zero-compromise rule "PHP Enums mandatory for all
|
||||
type/operator/status fields". Must be promoted to a backed PHP enum during
|
||||
implementation:
|
||||
|
||||
```php
|
||||
namespace App\Enums\Artist;
|
||||
|
||||
enum PerformanceBookingStatus: string {
|
||||
case Concept = 'concept';
|
||||
case Requested = 'requested';
|
||||
case Option = 'option';
|
||||
case Confirmed = 'confirmed';
|
||||
case Contracted = 'contracted';
|
||||
case Cancelled = 'cancelled';
|
||||
}
|
||||
```
|
||||
|
||||
Same enum is reused on `artists.booking_status` (currently inline string-enum
|
||||
in migration). Rename of existing column type to use the enum cast in the
|
||||
Eloquent model. No column-level migration needed — both are already string
|
||||
columns. SCHEMA.md needs update to reflect this in the implementation prompt.
|
||||
|
||||
## 5. Schema impact
|
||||
|
||||
### No new tables
|
||||
|
||||
All four needed tables exist in §3.5.7: `artists`, `performances`, `stages`,
|
||||
`stage_days`.
|
||||
|
||||
### Two column additions on `artists` (ARCH-09 migration)
|
||||
|
||||
```
|
||||
advancing_completed_count unsigned int default 0
|
||||
advancing_total_count unsigned int default 0
|
||||
```
|
||||
|
||||
Denormalised aggregate for D2 popover. Recomputed via
|
||||
`AdvanceSectionObserver`. Both columns indexed only as part of the artist
|
||||
PK (no separate index needed — read pattern is by artist).
|
||||
|
||||
### One enum upgrade
|
||||
|
||||
`performances.booking_status` cast to `PerformanceBookingStatus` enum at
|
||||
Eloquent layer. SCHEMA.md updated to reflect enum constraint in §3.5.7.
|
||||
|
||||
### Soft-delete strategy
|
||||
|
||||
- `artists`: soft-delete YES (already in schema)
|
||||
- `performances`: soft-delete YES (cascade with artist via observer; restore
|
||||
cascades back)
|
||||
- `stages`: soft-delete NO. Reason: deleting a stage is rare and destructive;
|
||||
if you mistakenly delete you can recreate. Soft-delete on stages adds
|
||||
query complexity (joining performances→stages with `withTrashed`) without
|
||||
meaningful safety win.
|
||||
- `stage_days`: pure pivot, no soft-delete, hard delete on day-toggle-off.
|
||||
|
||||
This is a deviation from the §3.5.10 default ("artists soft-delete yes,
|
||||
stages not listed") — explicitly noted here.
|
||||
|
||||
## 6. Routes & API
|
||||
|
||||
All routes scoped under `events.{event}` with `OrganisationScope` enforced
|
||||
via FK-chain (stage.event.organisation_id, performance.stage.event.organisation_id).
|
||||
|
||||
```
|
||||
GET /api/v1/events/{event}/stages
|
||||
POST /api/v1/events/{event}/stages
|
||||
GET /api/v1/events/{event}/stages/{stage}
|
||||
PATCH /api/v1/events/{event}/stages/{stage}
|
||||
DELETE /api/v1/events/{event}/stages/{stage}
|
||||
|
||||
PUT /api/v1/events/{event}/stages/{stage}/days
|
||||
Body: { day_dates: ["2026-07-10", "2026-07-11"] }
|
||||
Replaces stage_days for stage atomically. Returns 200 with
|
||||
new stage_days collection.
|
||||
|
||||
GET /api/v1/events/{event}/performances?day=2026-07-10
|
||||
POST /api/v1/events/{event}/performances
|
||||
GET /api/v1/events/{event}/performances/{performance}
|
||||
PATCH /api/v1/events/{event}/performances/{performance}
|
||||
DELETE /api/v1/events/{event}/performances/{performance}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The `PUT /stages/{stage}/days` endpoint is the matrix-editor backend.
|
||||
Single-day toggle from the stage editor uses the same endpoint with the
|
||||
full new array. This is REST-correct (replace resource collection) and
|
||||
avoids race conditions inherent in N individual POST/DELETE calls.
|
||||
- `GET /performances?day=YYYY-MM-DD` filters by `performances.date`. Without
|
||||
the parameter returns all performances for the event (used for cross-day
|
||||
artist views).
|
||||
- Resource includes: `artist:id,name,booking_status,advancing_completed_count,advancing_total_count`,
|
||||
`stage:id,name,color`. Always eager-loaded — no N+1.
|
||||
- Idempotency-Key header required on POST `/performances` and POST
|
||||
`/stages` per ARCH §10. Same pattern as form_submissions.
|
||||
|
||||
## 7. Frontend architecture
|
||||
|
||||
```
|
||||
apps/app/src/pages/events/[id]/timetable/
|
||||
index.vue ← page entry, day tab state, ?day query sync
|
||||
TimetableGrid.vue ← scroll container, time axis, stage rows
|
||||
StageRow.vue ← left cell + draggable performance row
|
||||
StageHeaderCell.vue ← swatch + name + capacity + day chips
|
||||
PerformanceBlock.vue ← single block; emits drag/resize/click events
|
||||
PerformancePopover.vue ← floating detail; teleported to body
|
||||
LineupMatrix.vue ← bulk stages × days editor (modal)
|
||||
StageEditor.vue ← single stage CRUD (modal)
|
||||
AddPerformanceDialog.vue ← click-to-add modal
|
||||
EmptyDayState.vue ← "no stages on this day, copy from..."
|
||||
|
||||
apps/app/src/composables/timetable/
|
||||
useTimetable.ts ← TanStack queries: stages, performances
|
||||
useTimetableMutations.ts ← performance CRUD mutations + optimistic updates
|
||||
useStageDays.ts ← stage-day matrix mutation
|
||||
useDragDrop.ts ← pointer event handling, snap math
|
||||
useTimetableConflicts.ts ← computed conflict + b2b state per day
|
||||
|
||||
apps/app/src/types/timetable.ts
|
||||
Stage, StageDay, Performance, PerformanceBookingStatus (zod schemas)
|
||||
|
||||
apps/app/src/api/timetable.ts
|
||||
Axios calls; types inferred from zod schemas
|
||||
```
|
||||
|
||||
State pattern:
|
||||
|
||||
- TanStack Query holds canonical server state.
|
||||
- Pinia store (`useTimetableStore`) holds *only* UI state (selectedDay,
|
||||
selectedPerformanceId, popoverPosition, dragState). Never duplicate
|
||||
server data into Pinia.
|
||||
- Optimistic updates on drag/drop and resize via `onMutate` rollback pattern.
|
||||
|
||||
Form validation: VeeValidate + Zod on AddPerformanceDialog and StageEditor.
|
||||
Schemas mirrored from `apps/app/src/types/timetable.ts`.
|
||||
|
||||
## 8. Activity log
|
||||
|
||||
Spatie ActivityLog with `LogsActivity` trait on Stage, Performance, Artist
|
||||
(latter already covered by ARCH-09).
|
||||
|
||||
Logged events:
|
||||
|
||||
- `stage.created`, `stage.updated`, `stage.deleted`
|
||||
- `stage.day_added`, `stage.day_removed` (custom events on PUT /days
|
||||
diffing the before/after sets)
|
||||
- `performance.created`, `performance.updated`, `performance.deleted`
|
||||
- `performance.moved` — special event when stage_id or date changes (high
|
||||
signal for production managers; surfaces in event audit log)
|
||||
|
||||
Stage and performance updates capture before/after on: name, color, capacity
|
||||
(stages); start_time, end_time, date, stage_id, booking_status (performances).
|
||||
|
||||
Activity log entries scoped to organisation + event for audit log filtering.
|
||||
|
||||
## 9. Authorization (Policy)
|
||||
|
||||
```
|
||||
StagePolicy:
|
||||
viewAny(User, Event) — user must be member of event.organisation
|
||||
view(User, Stage) — same + stage.event_id check
|
||||
create(User, Event) — user must have permission 'events.manage_program'
|
||||
update(User, Stage) — same as create + organisation match
|
||||
delete(User, Stage) — same as update + no related performances or
|
||||
performances are also deletable
|
||||
|
||||
PerformancePolicy:
|
||||
viewAny(User, Event) — same as Stage
|
||||
view(User, Performance) — via stage.event.organisation
|
||||
create(User, Event) — 'events.manage_program' permission
|
||||
update(User, Performance) — same as create
|
||||
delete(User, Performance) — same as create
|
||||
```
|
||||
|
||||
`events.manage_program` is a new Spatie permission to add. Roles that get it:
|
||||
`event_admin`, `program_manager` (new role — added in same sprint).
|
||||
Volunteers, crew, suppliers do not have it.
|
||||
|
||||
## 10. Validation rules
|
||||
|
||||
### CreatePerformanceRequest / UpdatePerformanceRequest
|
||||
|
||||
```
|
||||
artist_id required ulid exists:artists,id (scoped to event via stage)
|
||||
stage_id required ulid exists:stages,id (scoped to event)
|
||||
date required date
|
||||
between: event.start_date and event.end_date inclusive
|
||||
must exist in stage_days(stage_id=value of stage_id,
|
||||
day_date=value)
|
||||
→ custom rule StageActiveOnDay
|
||||
start_time required time format H:i
|
||||
end_time required time format H:i
|
||||
after start_time within same date
|
||||
(cross-midnight handled via end_date if needed; v1 forbids
|
||||
cross-midnight performances — flag as a known limitation)
|
||||
booking_status required PerformanceBookingStatus enum value
|
||||
check_in_status optional CheckInStatus enum, default 'expected'
|
||||
```
|
||||
|
||||
Overlap is **not** a hard validation error — it produces a 200 response with
|
||||
a `warnings: ["overlap"]` field in the resource. The frontend renders the
|
||||
conflict ring; the production manager decides whether to resolve.
|
||||
|
||||
### CreateStageRequest
|
||||
|
||||
```
|
||||
name required string max:120 unique:stages,name,NULL,id,event_id,{event_id}
|
||||
color required string regex:/^#[0-9A-Fa-f]{6}$/
|
||||
capacity nullable integer min:1 max:1000000
|
||||
active_days required array min:1
|
||||
active_days.* date between event.start_date and event.end_date
|
||||
```
|
||||
|
||||
### Stage-day matrix replacement (PUT /stages/{stage}/days)
|
||||
|
||||
```
|
||||
day_dates required array min:1 (a stage must always have ≥1 active day)
|
||||
day_dates.* date between event.start_date and event.end_date
|
||||
```
|
||||
|
||||
If the request would remove a day with scheduled non-cancelled performances,
|
||||
return 409 Conflict with `{performances_on_removed_days: [...]}`. The frontend
|
||||
shows the matrix-editor confirmation dialog and resends with
|
||||
`?force_orphan=true` to commit. The orphaned performances persist as soft
|
||||
references (still in DB, hidden from views) — recovered when the day is
|
||||
re-added.
|
||||
|
||||
## 11. Open questions / future work
|
||||
|
||||
- **Stage capacity-vs-draw warning** — requires `expected_attendance` column
|
||||
on performances OR `expected_draw_default` on artists. Add as ART-04 in
|
||||
backlog. Out of v1.
|
||||
- **Stage templates** — copy stage configuration across events. Backlog as
|
||||
ARCH-10. Out of v1.
|
||||
- **Artist double-booking detection across stages** — defer to artist page,
|
||||
not timetable. Backlog as ART-08.
|
||||
- **Cross-midnight performances** — currently forbidden by validation
|
||||
(end_time after start_time within same date). For show-day operations
|
||||
this is restrictive (a 23:30 → 00:30 set spans midnight). Pragmatic v1
|
||||
workaround: store as 23:30 → 24:30 with `end_time > 23:59:59` allowed
|
||||
via custom validation. Or add `end_date` column and treat properly.
|
||||
**Decision required before implementation.**
|
||||
|
||||
## 12. Implementation order
|
||||
|
||||
Three Claude Code sessions, sequential:
|
||||
|
||||
### Session 1 — ARCH-09 + Backend models
|
||||
|
||||
1. Artist model + migration + factory + seeder (ARCH-09)
|
||||
2. Update `PURPOSE_SUBJECT_FQCN` constant: string-literal → `Artist::class`
|
||||
3. Stage + StageDay + Performance models + migrations
|
||||
4. PHP Enums: PerformanceBookingStatus, CheckInStatus (artists)
|
||||
5. AdvanceSectionObserver to recompute advancing aggregate
|
||||
6. PerformanceObserver for cascade soft-delete with artist
|
||||
7. OrganisationScope registration on all three models (FK-chain)
|
||||
8. SCHEMA.md update (enum upgrade, advancing_*_count columns)
|
||||
|
||||
### Session 2 — Backend API + business logic
|
||||
|
||||
1. StagePolicy, PerformancePolicy
|
||||
2. New permission `events.manage_program`, new role `program_manager`
|
||||
3. StageService, PerformanceService (StageDayService for matrix replace)
|
||||
4. FormRequests (CreateStageRequest, UpdateStageRequest, etc.)
|
||||
5. Custom rules: StageActiveOnDay
|
||||
6. API Resources (StageResource, PerformanceResource with includes)
|
||||
7. Controllers (5 routes per module)
|
||||
8. Routes registered in api.php
|
||||
9. Activity log integration on all mutations
|
||||
10. Tests: feature tests for all endpoints, policy tests, validation tests,
|
||||
overlap-warning tests, day-removal-with-orphans flow
|
||||
11. API.md update
|
||||
|
||||
### Session 3 — Frontend
|
||||
|
||||
1. Types + zod schemas
|
||||
2. API client
|
||||
3. TanStack composables
|
||||
4. Pinia store (UI state only)
|
||||
5. Page entry + routing
|
||||
6. TimetableGrid + StageRow + StageHeaderCell
|
||||
7. PerformanceBlock with drag/resize composable
|
||||
8. PerformancePopover with advancing aggregate fetch
|
||||
9. AddPerformanceDialog
|
||||
10. StageEditor + LineupMatrix modals
|
||||
11. EmptyDayState
|
||||
12. Vitest component tests on critical math (snap, overlap, conflict)
|
||||
13. End-to-end flow test with Playwright (or whatever Crewli uses): drag
|
||||
performance, switch days, edit lineup matrix
|
||||
|
||||
Estimate: 2.5 + 2 + 3 = **7.5 days across three sessions**.
|
||||
|
||||
## 13. Test strategy
|
||||
|
||||
### Backend unit + feature
|
||||
|
||||
- StageActiveOnDay rule
|
||||
- PerformanceService overlap detection (warns, does not block)
|
||||
- StageDayService atomic replacement with orphan-performance handling
|
||||
- AdvanceSectionObserver count recomputation
|
||||
- PerformanceObserver cascade soft-delete with artist
|
||||
- All policy methods (positive + negative)
|
||||
- All endpoints (200, 201, 204, 401, 403, 404, 409, 422)
|
||||
|
||||
### Frontend
|
||||
|
||||
- Pure: snap math, conflict detection, B2B detection, day-filter
|
||||
- Component: PerformanceBlock renders correctly per booking_status
|
||||
- Component: drag invokes mutation with optimistic update + rollback on error
|
||||
- Integration: full add → drag → edit → delete flow with mocked API
|
||||
- Accessibility: keyboard navigation through stages, screen-reader on
|
||||
performance blocks (focus order, aria-labels)
|
||||
|
||||
## 14. Dependencies
|
||||
|
||||
### Hard
|
||||
|
||||
- ARCH-09 (Artist model) — must land in Session 1 before stages/performances
|
||||
can be created. Currently a string-literal `'App\\Models\\Artist'` in
|
||||
`PURPOSE_SUBJECT_FQCN`; first artist submission would fault. Resolves at
|
||||
start of Session 1.
|
||||
- New permission `events.manage_program` and role `program_manager` —
|
||||
Session 2 prerequisite.
|
||||
|
||||
### Soft
|
||||
|
||||
- ART-01 (Artist Advancing portal) — provides the advance_sections data
|
||||
that powers the popover aggregate. Without ART-01 the aggregate is always
|
||||
`0/0`. Acceptable for v1: the aggregate displays with grey "—" when
|
||||
total is 0.
|
||||
- ARCH-10 (stage templates) — would speed up setup of recurring events but
|
||||
is not blocking.
|
||||
|
||||
### None
|
||||
|
||||
- Form Builder, Accreditation Engine, Briefings — fully independent.
|
||||
@@ -0,0 +1,470 @@
|
||||
# Timetable Module — Implementatie-spec
|
||||
|
||||
> **Doel**: een interactieve timetable-module in de Crewli-app waarin programmers acts kunnen plannen op stages, slepen tussen stages/wachtrij, conflicten en advancing-status zien, en stages beheren.
|
||||
>
|
||||
> **Stack**: Vue 3 (Composition API) + Pinia + TypeScript op de Vuexy/Vuetify-template, Laravel 11 als API-backend, MySQL/Postgres, Reverb voor real-time.
|
||||
>
|
||||
> **Referentie-PoC**: zie `_poc/Crewli Timetable.html` (interactieve mockup met alle gewenste UX-gedrag — gebruik dit als executable spec).
|
||||
|
||||
---
|
||||
|
||||
## 1. Domain model
|
||||
|
||||
### 1.1 Tabellen
|
||||
|
||||
```text
|
||||
events
|
||||
id ulid PK
|
||||
name string # "Echt Zomer Feesten 2026"
|
||||
slug string unique
|
||||
start_hour tinyint # grid start, default 14 (14:00)
|
||||
total_minutes smallint # grid lengte, default 720 (12u)
|
||||
stages_label string # configureerbare term, default "Stages"
|
||||
created_at, updated_at, deleted_at
|
||||
|
||||
event_days
|
||||
id ulid PK
|
||||
event_id ulid FK -> events
|
||||
date date
|
||||
label string # "Vrijdag 12 juli"
|
||||
sort smallint
|
||||
unique(event_id, date)
|
||||
|
||||
stages
|
||||
id ulid PK
|
||||
event_id ulid FK -> events
|
||||
name string
|
||||
color string(7) # hex
|
||||
capacity int # voor draw-warnings
|
||||
sort smallint # globale fallback-volgorde
|
||||
created_at, updated_at, deleted_at # soft delete
|
||||
|
||||
stage_days # welke stages zijn op welke dag actief
|
||||
id ulid PK
|
||||
stage_id ulid FK -> stages
|
||||
day_id ulid FK -> event_days
|
||||
sort smallint # volgorde van stages binnen die dag
|
||||
unique(stage_id, day_id)
|
||||
|
||||
artists
|
||||
id ulid PK
|
||||
event_id ulid FK -> events
|
||||
name string
|
||||
initials string(4)
|
||||
genre string # enum-string, freeform mag
|
||||
draw int # verwachte trekkracht
|
||||
created_at, updated_at, deleted_at
|
||||
|
||||
advance_sections # configureerbaar per event
|
||||
id ulid PK
|
||||
event_id ulid FK -> events
|
||||
key string # 'tour' | 'hosp' | 'travel' | ...
|
||||
label string
|
||||
sort smallint
|
||||
unique(event_id, key)
|
||||
|
||||
artist_advance # pivot: welke secties zijn done
|
||||
artist_id ulid FK
|
||||
section_id ulid FK
|
||||
done_at timestamp nullable
|
||||
primary key (artist_id, section_id)
|
||||
|
||||
performances # GEPLAND op een stage
|
||||
id ulid PK
|
||||
event_id ulid FK
|
||||
day_id ulid FK -> event_days
|
||||
stage_id ulid FK -> stages
|
||||
artist_id ulid FK -> artists
|
||||
start_minute smallint # offset vanaf event.start_hour*60
|
||||
end_minute smallint
|
||||
lane tinyint # sub-swimlane within stage row
|
||||
status enum('concept','requested','option','confirmed','cancelled')
|
||||
notes text nullable
|
||||
version int default 0 # optimistic locking
|
||||
created_at, updated_at, deleted_at
|
||||
index(day_id, stage_id, start_minute)
|
||||
|
||||
parked_performances # WACHTRIJ — niet gepland
|
||||
id ulid PK
|
||||
event_id ulid FK
|
||||
day_id ulid FK
|
||||
artist_id ulid FK
|
||||
duration_minutes smallint # voorgenomen duur
|
||||
status enum(...) # zelfde set als performances
|
||||
notes text nullable
|
||||
origin enum('manual','unscheduled','stage_deleted')
|
||||
created_at, updated_at
|
||||
|
||||
pending_performances # nog niet bevestigde aanvragen
|
||||
id ulid PK
|
||||
event_id ulid FK
|
||||
day_id ulid FK
|
||||
artist_id ulid FK
|
||||
duration_minutes smallint
|
||||
notes text nullable
|
||||
created_at, updated_at
|
||||
|
||||
audit_log # wie deed wat wanneer
|
||||
id ulid PK
|
||||
user_id ulid FK -> users
|
||||
event_id ulid FK
|
||||
subject_type string # 'performance' | 'stage' | ...
|
||||
subject_id ulid
|
||||
action string # 'move' | 'create' | 'park' | ...
|
||||
before json nullable
|
||||
after json nullable
|
||||
created_at
|
||||
index(event_id, subject_type, subject_id)
|
||||
```
|
||||
|
||||
### 1.2 Invarianten
|
||||
|
||||
1. Een artiest mag op één moment maar één performance hebben (cross-stage conflict). Backend valideert; frontend toont waarschuwing maar blokkeert niet.
|
||||
2. Op één stage mogen meerdere performances *op verschillende lanes* tegelijk lopen. Op dezelfde lane mag geen tijds-overlap.
|
||||
3. `start_minute < end_minute`, beide binnen `[0, event.total_minutes]`.
|
||||
4. `stage_id` moet een actieve `stage_day` hebben voor `day_id`.
|
||||
5. Soft-delete van een stage cascadet alle bijbehorende `performances` naar `parked_performances` met `origin='stage_deleted'`.
|
||||
|
||||
### 1.3 Status-enum
|
||||
|
||||
```
|
||||
concept # idee, niet aangevraagd
|
||||
requested # aangevraagd bij agent/artiest
|
||||
option # geboekt onder optie
|
||||
confirmed # bevestigd
|
||||
cancelled # geannuleerd; blijft zichtbaar met diagonaal patroon
|
||||
```
|
||||
|
||||
Status-kleuren staan vast in de frontend theme (zie §6).
|
||||
|
||||
---
|
||||
|
||||
## 2. API-contract
|
||||
|
||||
Alle endpoints onder `/api/v1`, JSON, Sanctum auth. Resources gebruiken `EventResource`, `PerformanceResource`, etc.
|
||||
|
||||
### 2.1 Read
|
||||
|
||||
| Method | Path | Doel |
|
||||
|---|---|---|
|
||||
| `GET` | `/events/{event}` | Event-meta + days + stages + advance_sections |
|
||||
| `GET` | `/events/{event}/timetable?day={dayId}` | Performances + parked + pending voor één dag, in één response |
|
||||
| `GET` | `/events/{event}/artists` | Volledige artiestenlijst (voor add-modal autocomplete) |
|
||||
|
||||
**Response shape `/timetable`**:
|
||||
```json
|
||||
{
|
||||
"day": { "id": "...", "label": "Vrijdag 12 juli" },
|
||||
"stages": [{ "id": "...", "name": "...", "color": "...", "capacity": 2400, "sort": 0 }],
|
||||
"performances": [{ "id": "...", "stage_id": "...", "artist_id": "...", "start": 480, "end": 540, "lane": 0, "status": "confirmed", "version": 3 }],
|
||||
"parked": [...],
|
||||
"pending": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Write — performances
|
||||
|
||||
| Method | Path | Body | Effect |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/performances` | `{ day_id, stage_id?, artist_id, start?, end?, status, lane? }` | Nieuw — als `stage_id` ontbreekt → wachtrij |
|
||||
| `PATCH` | `/performances/{id}` | `{ start?, end?, stage_id?, lane?, status?, version }` | Move/resize/restage. Bij `version` mismatch → 409 |
|
||||
| `DELETE` | `/performances/{id}` | — | Verwijder volledig |
|
||||
| `POST` | `/performances/{id}/park` | — | Verplaatst naar `parked_performances` |
|
||||
| `POST` | `/parked/{id}/schedule` | `{ stage_id, start, end, lane? }` | Wachtrij → timetable, met cascade-bump als lane bezet |
|
||||
| `POST` | `/pending/{id}/schedule` | `{ stage_id, start, end, lane? }` | Pending → timetable |
|
||||
| `DELETE` | `/parked/{id}` of `/pending/{id}` | — | Verwijder uit wachtrij |
|
||||
|
||||
**Cascade-logica** (lane-conflict op drop): backend ontvangt expliciete `lane`. Als die lane qua tijd bezet is op die stage, *bumpt het bestaande block* één lane omlaag, recursief. Backend retourneert *alle* gewijzigde performances in een `cascade[]` array zodat de frontend in één state-update kan patchen.
|
||||
|
||||
```json
|
||||
// PATCH response
|
||||
{
|
||||
"performance": { ...updated... },
|
||||
"cascade": [{ ...other... }, ...]
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Write — stages
|
||||
|
||||
| Method | Path | Body | Effect |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/stages` | `{ event_id, name, color, capacity, day_ids[] }` | Nieuwe stage |
|
||||
| `PATCH` | `/stages/{id}` | `{ name?, color?, capacity?, day_ids? }` | Update + sync `stage_days` |
|
||||
| `DELETE` | `/stages/{id}` | — | Soft delete + cascade performances → parked |
|
||||
| `POST` | `/stages/reorder` | `{ day_id, stage_ids[] }` | Per-dag volgorde |
|
||||
|
||||
### 2.4 Errors
|
||||
|
||||
```
|
||||
400 Bad Request # validatie (start >= end, etc.)
|
||||
403 Forbidden # geen permissie
|
||||
404 Not Found
|
||||
409 Conflict # version mismatch (optimistic lock)
|
||||
422 Unprocessable # business rule (artiest dubbel geboekt)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Frontend architectuur
|
||||
|
||||
### 3.1 Bestandsstructuur
|
||||
|
||||
```
|
||||
resources/js/timetable/
|
||||
├─ index.ts # public exports + route
|
||||
├─ types.ts # Performance, Stage, Artist, Drag, ...
|
||||
├─ lib/
|
||||
│ ├─ lanes.ts # assignLanes() + sub-swimlane logic
|
||||
│ ├─ conflicts.ts # findConflicts, findB2B
|
||||
│ ├─ time.ts # fmtTime, snap
|
||||
│ ├─ advance.ts # advanceCount
|
||||
│ └─ __tests__/
|
||||
├─ stores/
|
||||
│ └─ timetable.ts # Pinia store
|
||||
├─ composables/
|
||||
│ ├─ useTimetableDrag.ts
|
||||
│ ├─ useTimetableScroll.ts
|
||||
│ └─ useRealtime.ts
|
||||
├─ components/
|
||||
│ ├─ Timetable.vue # canvas + axis + rows
|
||||
│ ├─ TimetableHeader.vue # day tabs, conflict counter, +Performance
|
||||
│ ├─ TimetableToolbar.vue # density, snap, percent toggle, +Performance
|
||||
│ ├─ Block.vue # één performance-block
|
||||
│ ├─ StageRow.vue
|
||||
│ ├─ Wachtrij.vue
|
||||
│ ├─ Popover.vue
|
||||
│ ├─ Drawer.vue
|
||||
│ ├─ modals/
|
||||
│ │ ├─ PerformanceModal.vue
|
||||
│ │ ├─ StageEditor.vue
|
||||
│ │ └─ LineupMatrix.vue
|
||||
│ └─ __tests__/
|
||||
├─ scss/
|
||||
│ └─ timetable.scss # gebruikt Vuexy SCSS-tokens
|
||||
└─ pages/
|
||||
└─ EventTimetable.vue # route entrypoint
|
||||
```
|
||||
|
||||
### 3.2 Pinia store skelet
|
||||
|
||||
```ts
|
||||
// stores/timetable.ts
|
||||
export const useTimetableStore = defineStore('timetable', () => {
|
||||
const event = ref<Event | null>(null)
|
||||
const activeDayId = ref<string | null>(null)
|
||||
const stages = ref<Stage[]>([])
|
||||
const performances = ref<Map<string, Performance>>(new Map())
|
||||
const parked = ref<ParkedPerformance[]>([])
|
||||
const pending = ref<PendingPerformance[]>([])
|
||||
const artists = ref<Artist[]>([])
|
||||
|
||||
// ── Computed ─────────────────────────────
|
||||
const dayStages = computed(() => /* filter by stage_days for activeDayId */)
|
||||
const dayPerformances = computed(() => /* filter by day_id */)
|
||||
const lanesByStage = computed(() => /* assignLanes per stage */)
|
||||
|
||||
// ── Actions (alle optimistic + rollback) ─
|
||||
async function loadDay(eventId: string, dayId: string) { ... }
|
||||
async function movePerf(id: string, patch: PerfPatch) { ... }
|
||||
async function createPerf(input: NewPerf) { ... }
|
||||
async function parkPerf(id: string) { ... }
|
||||
async function schedulePending(pendingId: string, target: Target) { ... }
|
||||
async function scheduleParked(parkedId: string, target: Target) { ... }
|
||||
async function deletePerf(id: string) { ... }
|
||||
async function addStage(input: NewStage) { ... }
|
||||
async function updateStage(id: string, patch: StagePatch) { ... }
|
||||
async function deleteStage(id: string) { ... }
|
||||
async function reorderStages(dayId: string, stageIds: string[]) { ... }
|
||||
|
||||
// ── Realtime patches (Reverb) ────────────
|
||||
function applyRemotePatch(event: BroadcastEvent) { ... }
|
||||
|
||||
return { /* state + computed + actions */ }
|
||||
})
|
||||
```
|
||||
|
||||
**Optimistic update pattern** (gebruik overal):
|
||||
```ts
|
||||
async function movePerf(id, patch) {
|
||||
const before = performances.value.get(id)
|
||||
performances.value.set(id, { ...before, ...patch }) // 1. local apply
|
||||
try {
|
||||
const { data } = await api.patch(`/performances/${id}`, { ...patch, version: before.version })
|
||||
performances.value.set(id, data.performance) // 2. authoritative replace
|
||||
data.cascade.forEach(p => performances.value.set(p.id, p)) // 3. apply cascade
|
||||
} catch (e) {
|
||||
performances.value.set(id, before) // 4. rollback
|
||||
if (e.status === 409) toast.warn('Iemand anders heeft dit zojuist aangepast — ververs')
|
||||
else toast.error('Opslaan mislukt')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Drag-drop composable
|
||||
|
||||
Port `startDragMove`, `startDragResize`, `startCreateDrag`, `startDragFromParking` van de PoC (`timetable.jsx`) **letterlijk** naar één composable. Gebruik `ref` voor `drag`-state en window-scoped `mousemove`/`mouseup` listeners. Cleanup via `onScopeDispose`.
|
||||
|
||||
Belangrijke regels die de PoC al heeft uitgewerkt — port verbatim:
|
||||
- **Lane-snap**: `Math.floor(yInRow / LANE_STEP)` voor create, `Math.round(...)` voor move
|
||||
- **Cursor-anchor cross-stage move**: `newRow` afgeleid van cursor-Y, niet block-top
|
||||
- **Click-after-drag suppressie**: zet `data-just-dragged` op het block bij mouseup-na-drag, fail-fast in click handler
|
||||
- **Timestamp-only conflict check**: `o.start < newEnd && o.end > newStart` — nooit pixel-Y meenemen in conflict-bepaling
|
||||
|
||||
### 3.4 Pure-logic ports
|
||||
|
||||
`lib/lanes.ts`, `lib/conflicts.ts` etc. zijn **directe TypeScript-ports** van `helpers.js`. Begin hiermee — schrijf eerst de Vitest tests en check ze tegen de PoC-output. Dit is de hoeksteen; alles wat erop bouwt is daarna voorspelbaar.
|
||||
|
||||
---
|
||||
|
||||
## 4. Realtime (Reverb + Echo)
|
||||
|
||||
### 4.1 Channels
|
||||
|
||||
```
|
||||
private-event.{eventId}.timetable
|
||||
```
|
||||
|
||||
Geautoriseerd in `routes/channels.php` via `Gate::allows('view-event-timetable', $event)`.
|
||||
|
||||
### 4.2 Events
|
||||
|
||||
```php
|
||||
PerformanceCreated { performance: PerformanceResource }
|
||||
PerformanceUpdated { performance: ..., cascade: [...] }
|
||||
PerformanceDeleted { id: ulid }
|
||||
PerformanceParked { performance: ..., parked: ParkedResource }
|
||||
ParkedScheduled { parked_id: ulid, performance: ... }
|
||||
StageCreated/Updated/Deleted/Reordered
|
||||
```
|
||||
|
||||
Elke event-class heeft `socket()` returning `request()->header('X-Socket-Id')` — clients filteren hun eigen broadcasts uit, zo voorkom je dubbele optimistic updates.
|
||||
|
||||
### 4.3 Frontend wiring
|
||||
|
||||
```ts
|
||||
// composables/useRealtime.ts
|
||||
export function useRealtime(eventId: string) {
|
||||
const store = useTimetableStore()
|
||||
const channel = window.Echo.private(`event.${eventId}.timetable`)
|
||||
channel.listen('PerformanceUpdated', (e) => store.applyRemotePatch(e))
|
||||
// ...
|
||||
onScopeDispose(() => channel.stopListening())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Permissies (CASL)
|
||||
|
||||
Definieer in `resources/js/abilities.ts`:
|
||||
|
||||
```ts
|
||||
can('view', 'Timetable', { event_id: ... })
|
||||
can('edit', 'Performance', { event_id: ... })
|
||||
can('manage', 'Stage', { event_id: ... })
|
||||
can('book', 'Performance', { status: ['concept','requested','option'] })
|
||||
```
|
||||
|
||||
UI: knoppen/handles disablen via `v-if="$can('edit', 'Performance')"`. Backend valideert *altijd* opnieuw via `PerformancePolicy`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Styling
|
||||
|
||||
### 6.1 Tokens (Vuexy)
|
||||
|
||||
```scss
|
||||
// scss/timetable.scss — gebruikt Vuexy core variables
|
||||
@use '@core/scss/base/variables' as *;
|
||||
@use '@core/scss/base/mixins' as *;
|
||||
|
||||
:root {
|
||||
// Status-kleuren — toevoegen aan Vuetify theme onder customProperties:
|
||||
--tt-status-concept-bg: #f4f5f8;
|
||||
--tt-status-concept-bd: #c8ccd6;
|
||||
--tt-status-requested-bg: #fff5e6;
|
||||
--tt-status-requested-bd: #e89a3c;
|
||||
--tt-status-option-bg: #e9efff;
|
||||
--tt-status-option-bd: #5a8fcf;
|
||||
--tt-status-confirmed-bg: #e8f7ee;
|
||||
--tt-status-confirmed-bd: #3cc28a;
|
||||
--tt-status-cancelled-bd: #d63d4b;
|
||||
}
|
||||
|
||||
.cw-block {
|
||||
border-radius: $border-radius-sm;
|
||||
background: var(--v-theme-surface);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Voor dark-mode: definieer alle `--tt-status-*-*` ook in `[data-theme="dark"]`.
|
||||
|
||||
### 6.2 Vuetify-componenten waar nuttig
|
||||
|
||||
Header tabs → `<VTabs>`. Modals → `<VDialog>`. Density-toggle → `<VBtnToggle>`. Drawer → `<VNavigationDrawer location="right">`. Toasts → `<VSnackbar>` of `vue-toastification`.
|
||||
|
||||
**Niet** Vuetify gebruiken voor: het canvas, de blocks, de wachtrij-cards, de popover. Die zijn custom — past geen library-component bij.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migratie-volgorde
|
||||
|
||||
Bouw in deze volgorde, één PR per stap:
|
||||
|
||||
1. **Migrations + seeders** met testdata (kopieer `data.js` uit PoC als seeder-script)
|
||||
2. **Models + Resources + Policies + tests**
|
||||
3. **API endpoints + Feature tests** (volledige roundtrip per endpoint)
|
||||
4. **`lib/*.ts` ports + Vitest** — alleen pure logic
|
||||
5. **Pinia store + mock-API tests**
|
||||
6. **`Timetable.vue` + `Block.vue` + `StageRow.vue`** — read-only render
|
||||
7. **Drag composable + drop wiring** (move, resize, restage)
|
||||
8. **Wachtrij + create-drag + parked-drop**
|
||||
9. **Modals (PerformanceModal, StageEditor, LineupMatrix)**
|
||||
10. **Popover + Drawer**
|
||||
11. **Realtime (Reverb)**
|
||||
12. **Audit log + permissions**
|
||||
13. **Print-view + lock-feature** (later)
|
||||
|
||||
Elke stap heeft een **definition of done**: PR groen (tests + Pint + ESLint), screenshot in PR-beschrijving, één code-review.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing strategy
|
||||
|
||||
| Laag | Tool | Coverage-doel |
|
||||
|---|---|---|
|
||||
| Pure logic (`lib/`) | Vitest | 100% — edge cases uit PoC |
|
||||
| Pinia store | Vitest + mock fetch | Alle actions, optimistic + rollback |
|
||||
| Components | Vitest + @vue/test-utils | Smoke + interaction op kritieke flows |
|
||||
| API | Pest | Per endpoint: happy + 4 edge cases + permissie |
|
||||
| E2E | Playwright | 3 critical flows: drag-drop, stage-delete-cascade, realtime-sync |
|
||||
|
||||
---
|
||||
|
||||
## 9. Open vragen (beslis vóór de eerste sprint)
|
||||
|
||||
1. **Multi-event per organisatie**: één gebruiker kan meerdere events tegelijk plannen → `event_id` overal scope, of subdomain per event?
|
||||
2. **Wachtrij per dag of per event**: PoC toont wachtrij gefilterd op actieve dag. Confirm: dat is de spec.
|
||||
3. **Lane storage**: bewaar je `lane` op de DB of bereken je het altijd opnieuw bij elk render? PoC bewaart het — dat is mijn aanbeveling (anders kan een gebruiker geen explicit lane-keuze maken).
|
||||
4. **Conflict policy**: blokkeren of waarschuwen? PoC waarschuwt + toont rode rand. Confirm.
|
||||
5. **Print-formaat**: A3 landscape per dag? Of één file met alle dagen?
|
||||
6. **Audit retention**: hoe lang bewaren? GDPR-relevant.
|
||||
|
||||
---
|
||||
|
||||
## 10. Bronbestanden
|
||||
|
||||
PoC ligt in `_poc/Crewli Timetable.html` (zelf-bevattend). De relevante files:
|
||||
|
||||
- `app.jsx` — root, Pinia-equivalent state
|
||||
- `timetable.jsx` — grid, blocks, drag, create-drag, ghost
|
||||
- `popover.jsx` — popover + drawer + wachtrij
|
||||
- `modals.jsx` — PerformanceModal, StageEditor, LineupMatrix
|
||||
- `helpers.js` — pure logic (port als eerste!)
|
||||
- `data.js` — demo data (seeder-template)
|
||||
- `styles.css` — visuele referentie voor de SCSS-port
|
||||
|
||||
---
|
||||
|
||||
**Eind van spec — klaar om aan Claude Code te voeden, één hoofdstuk tegelijk.**
|
||||
144
resources/Crewli - Artist Timetable Management/helpers.js
Normal file
144
resources/Crewli - Artist Timetable Management/helpers.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// Shared helpers + status palette — matches Crewli light-mode look
|
||||
window.CrewliHelpers = (function () {
|
||||
// Status palette tuned to Crewli's light surface + teal accent.
|
||||
// Each status: pill bg/fg for popover & inline switches; block bg/border for timetable rows.
|
||||
const STATUS = {
|
||||
concept: {
|
||||
label: "Concept",
|
||||
pillBg: "#eceae6", pillFg: "#5a574e",
|
||||
blockBg: "#f1efe9", blockBorder: "#dcd9d1", blockFg: "#3a3830",
|
||||
dot: "#a09c92",
|
||||
},
|
||||
requested: {
|
||||
label: "Aangevraagd",
|
||||
pillBg: "#fdf2dc", pillFg: "#8a6a1d",
|
||||
blockBg: "#fff6e0", blockBorder: "#f0d99a", blockFg: "#5d4612",
|
||||
dot: "#d9a93c",
|
||||
},
|
||||
option: {
|
||||
label: "Optie",
|
||||
pillBg: "#ece6f6", pillFg: "#5d4a8a",
|
||||
blockBg: "#f3eefa", blockBorder: "#d9c9ed", blockFg: "#3f2f6a",
|
||||
dot: "#9a82c7",
|
||||
},
|
||||
confirmed: {
|
||||
label: "Bevestigd",
|
||||
pillBg: "#dff5ec", pillFg: "#1f7a5e",
|
||||
blockBg: "#e8f8f0", blockBorder: "#a9e0c8", blockFg: "#125541",
|
||||
dot: "#3cc2a8",
|
||||
},
|
||||
contracted: {
|
||||
label: "Getekend",
|
||||
pillBg: "#dcecfa", pillFg: "#1f5a8a",
|
||||
blockBg: "#e6f1fb", blockBorder: "#a8cfee", blockFg: "#143b5d",
|
||||
dot: "#3a8acc",
|
||||
},
|
||||
cancelled: {
|
||||
label: "Geannuleerd",
|
||||
pillBg: "#f0eeea", pillFg: "#8a8780",
|
||||
blockBg: "#f5f3ef", blockBorder: "#dedbd3", blockFg: "#7a7770",
|
||||
dot: "#a8a59d",
|
||||
},
|
||||
};
|
||||
|
||||
// minute-of-grid -> "HH:MM" (handles past-midnight rollover)
|
||||
function fmtTime(minOfGrid, startHour) {
|
||||
const total = startHour * 60 + minOfGrid;
|
||||
const h = Math.floor(total / 60) % 24;
|
||||
const m = total % 60;
|
||||
return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0");
|
||||
}
|
||||
function snap(min, step) { return Math.round(min / step) * step; }
|
||||
|
||||
function findConflicts(performances) {
|
||||
const conflicts = new Set();
|
||||
const byStage = {};
|
||||
for (const p of performances) {
|
||||
if (p.status === "cancelled") continue;
|
||||
(byStage[p.stage_id] = byStage[p.stage_id] || []).push(p);
|
||||
}
|
||||
for (const sid in byStage) {
|
||||
const list = byStage[sid].slice().sort((a, b) => a.start - b.start);
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
if (list[j].start < list[i].end) {
|
||||
conflicts.add(list[i].id);
|
||||
conflicts.add(list[j].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
function findB2B(performances, gap = 5) {
|
||||
const links = [];
|
||||
const byStage = {};
|
||||
for (const p of performances) {
|
||||
if (p.status === "cancelled") continue;
|
||||
(byStage[p.stage_id] = byStage[p.stage_id] || []).push(p);
|
||||
}
|
||||
for (const sid in byStage) {
|
||||
const list = byStage[sid].slice().sort((a, b) => a.start - b.start);
|
||||
for (let i = 0; i < list.length - 1; i++) {
|
||||
const gapMin = list[i + 1].start - list[i].end;
|
||||
if (gapMin >= 0 && gapMin <= gap) {
|
||||
links.push({ leftId: list[i].id, rightId: list[i + 1].id, gap: gapMin });
|
||||
}
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
function isCapacityWarn(artist, stage) {
|
||||
if (!artist || !stage) return false;
|
||||
return artist.draw > stage.capacity * 1.1;
|
||||
}
|
||||
|
||||
// Lane-pack: assign each item a lane index so overlapping items stack vertically.
|
||||
// Items with explicit `lane` (integer) are honored exactly — even if they overlap
|
||||
// another item on the same lane (that's a real conflict and shows visually).
|
||||
// Items without `lane` get placed in the lowest lane that has no time-overlap
|
||||
// with already-placed items (explicit OR previously auto-placed).
|
||||
// Returns { laneOf: {id: lane}, laneCount }
|
||||
function assignLanes(items) {
|
||||
const sorted = items.slice().sort((a, b) => a.start - b.start || a.id.localeCompare(b.id));
|
||||
const laneOf = {};
|
||||
let maxLane = 0;
|
||||
|
||||
const overlapsAt = (it, lane) => sorted.some(other =>
|
||||
other.id !== it.id &&
|
||||
laneOf[other.id] === lane &&
|
||||
other.start < it.end && other.end > it.start
|
||||
);
|
||||
|
||||
// Pass 1 — items with explicit lane (sorted by lane asc): try requested lane,
|
||||
// bump down on conflict to avoid overlap.
|
||||
const explicit = sorted.filter(i => Number.isInteger(i.lane))
|
||||
.sort((a, b) => a.lane - b.lane || a.start - b.start);
|
||||
for (const it of explicit) {
|
||||
let lane = Math.max(0, it.lane);
|
||||
while (overlapsAt(it, lane)) lane++;
|
||||
laneOf[it.id] = lane;
|
||||
if (lane > maxLane) maxLane = lane;
|
||||
}
|
||||
|
||||
// Pass 2 — items without explicit lane: lowest free lane.
|
||||
for (const it of sorted) {
|
||||
if (it.id in laneOf) continue;
|
||||
let lane = 0;
|
||||
while (overlapsAt(it, lane)) lane++;
|
||||
laneOf[it.id] = lane;
|
||||
if (lane > maxLane) maxLane = lane;
|
||||
}
|
||||
return { laneOf, laneCount: maxLane + 1 };
|
||||
}
|
||||
|
||||
function advanceCount(artist, sections) {
|
||||
let done = 0;
|
||||
for (const s of sections) if (artist.advance && artist.advance[s.key]) done++;
|
||||
return { done, total: sections.length };
|
||||
}
|
||||
|
||||
return { STATUS, fmtTime, snap, findConflicts, findB2B, isCapacityWarn, advanceCount, assignLanes };
|
||||
})();
|
||||
379
resources/Crewli - Artist Timetable Management/modals.jsx
Normal file
379
resources/Crewli - Artist Timetable Management/modals.jsx
Normal file
@@ -0,0 +1,379 @@
|
||||
// Modals: AddPerformance, EditPerformance, StageEditor, LineupMatrix
|
||||
// Each follows the same Crewli light-mode chrome.
|
||||
|
||||
const M = (function () {
|
||||
const ESC_KEY = 27;
|
||||
|
||||
function Backdrop({ children, onClose }) {
|
||||
React.useEffect(() => {
|
||||
const h = (e) => { if (e.keyCode === ESC_KEY) onClose(); };
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, []);
|
||||
return (
|
||||
<div className="cw-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, error, children }) {
|
||||
return (
|
||||
<label className={"cw-field" + (error ? " cw-field-error" : "")}>
|
||||
<span className="cw-field-lbl">{label}</span>
|
||||
{children}
|
||||
{error
|
||||
? <span className="cw-field-err" role="alert">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
|
||||
<circle cx="6" cy="6" r="5.4" fill="#d63d4b"/>
|
||||
<rect x="5.4" y="3" width="1.2" height="3.6" fill="white"/>
|
||||
<rect x="5.4" y="7.4" width="1.2" height="1.2" fill="white"/>
|
||||
</svg>
|
||||
{error}
|
||||
</span>
|
||||
: hint && <span className="cw-field-hint">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Add / Edit Performance ─────────────────────────────────────────
|
||||
// "Add" creates a brand-new act (artist + performance) on the fly.
|
||||
// Fields: Naam Act, Genre, Status. Stage/tijd komen mee als de popup
|
||||
// geopend is via een lege cel in de timetable — starttijd is dan
|
||||
// alleen-lezen, eindtijd = start + 1u.
|
||||
function PerformanceModal({ mode, perf, day, stages, artists, onSave, onClose, onDelete }) {
|
||||
const D = window.CrewliData;
|
||||
const H = window.CrewliHelpers;
|
||||
const fromTimetable = mode === "add" && perf?.stage_id != null && perf?.start != null;
|
||||
const start = fromTimetable ? perf.start : null;
|
||||
const end = fromTimetable ? perf.start + 60 : null;
|
||||
|
||||
// Edit mode preloads the existing artist; add mode is a blank act form.
|
||||
const existingArtist = mode === "edit" ? artists.find(a => a.id === perf?.artist_id) : null;
|
||||
const [name, setName] = React.useState(existingArtist?.name || "");
|
||||
const [genre, setGenre] = React.useState(existingArtist?.genre || D.GENRES[0]);
|
||||
const [status, setStatus] = React.useState(perf?.status || (fromTimetable ? "concept" : "requested"));
|
||||
// Duur (alleen relevant bij + Performance vanuit header — wachtrij heeft geen tijd)
|
||||
const [duration, setDuration] = React.useState(60);
|
||||
|
||||
const stage = fromTimetable ? stages.find(s => s.id === perf.stage_id) : null;
|
||||
const startStr = start != null ? H.fmtTime(start, D.TIME.startHour) : null;
|
||||
const endStr = end != null ? H.fmtTime(end, D.TIME.startHour) : null;
|
||||
|
||||
const [touched, setTouched] = React.useState(false);
|
||||
const trimmed = name.trim();
|
||||
const nameError = trimmed.length === 0 ? "Naam Act is verplicht"
|
||||
: trimmed.length > 60 ? "Maximaal 60 tekens"
|
||||
: null;
|
||||
const canSave = !nameError;
|
||||
|
||||
function save() {
|
||||
setTouched(true);
|
||||
if (!canSave) return;
|
||||
|
||||
if (mode === "edit") {
|
||||
// Edit only updates status here (rename/genre niet binnen scope).
|
||||
onSave({ artistUpdate: null, perf: { ...perf, status } });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Add: create artist + performance ──
|
||||
// Initials: keep only Latin letters/digits to avoid weird glyphs in the avatar.
|
||||
const safeChars = trimmed.replace(/[^A-Za-z0-9 ]/g, "").trim();
|
||||
const parts = safeChars.split(/\s+/).filter(Boolean);
|
||||
let initials = parts.length >= 2
|
||||
? (parts[0][0] + parts[1][0])
|
||||
: (parts[0] ? parts[0].slice(0, 2) : "");
|
||||
initials = initials.toUpperCase() || "??";
|
||||
const newArtist = {
|
||||
id: "a_" + Math.random().toString(36).slice(2, 8),
|
||||
name: name.trim(),
|
||||
initials,
|
||||
genre,
|
||||
draw: 0,
|
||||
advance: { tour: false, hosp: false, travel: false, flight: false, rider: false },
|
||||
};
|
||||
const basePerf = {
|
||||
id: "p_" + Math.random().toString(36).slice(2, 8),
|
||||
artist_id: newArtist.id,
|
||||
day_id: day.id,
|
||||
status,
|
||||
};
|
||||
if (fromTimetable) {
|
||||
const lanePart = Number.isInteger(perf.lane) ? { lane: perf.lane } : {};
|
||||
onSave({ newArtist, perf: { ...basePerf, stage_id: perf.stage_id, start, end, ...lanePart } });
|
||||
} else {
|
||||
// Geen timetable-context → wachtrij. Bewaar duur zodat block bij drop de juiste breedte krijgt.
|
||||
onSave({ newArtist, perf: { ...basePerf, stage_id: null, dur: duration } });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Backdrop onClose={onClose}>
|
||||
<div className="cw-modal" style={{ width: 480 }} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="cw-modal-hd">
|
||||
<div className="cw-modal-title">
|
||||
{mode === "add" ? "Performance toevoegen" : "Performance bewerken"}
|
||||
<span className="cw-modal-sub">{day.label} · {D.EVENT.name}</span>
|
||||
</div>
|
||||
<button className="cw-icon-btn" onClick={onClose} aria-label="Sluiten">×</button>
|
||||
</div>
|
||||
<div className="cw-modal-body">
|
||||
<Field label="Naam Act" error={touched && nameError ? nameError : null}>
|
||||
<input className={"cw-input" + (touched && nameError ? " cw-input-error" : "")}
|
||||
type="text" autoFocus
|
||||
placeholder="bijv. Mau P"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => setTouched(true)}
|
||||
disabled={mode === "edit"}
|
||||
maxLength={80}
|
||||
aria-invalid={touched && !!nameError}
|
||||
aria-describedby={touched && nameError ? "cw-name-err" : undefined} />
|
||||
</Field>
|
||||
|
||||
<div className="cw-row-2">
|
||||
<Field label="Genre">
|
||||
<select className="cw-input" value={genre}
|
||||
onChange={(e) => setGenre(e.target.value)}
|
||||
disabled={mode === "edit"}>
|
||||
{D.GENRES.map(g => <option key={g} value={g}>{g}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<select className="cw-input" value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}>
|
||||
{Object.keys(H.STATUS).map(k =>
|
||||
<option key={k} value={k}>{H.STATUS[k].label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{fromTimetable && stage && (
|
||||
<div className="cw-mctx">
|
||||
<div className="cw-mctx-stage">
|
||||
<span className="cw-mctx-stage-bar" style={{ background: stage.color }}></span>
|
||||
<div className="cw-mctx-stage-body">
|
||||
<span className="cw-mctx-eyebrow">Stage</span>
|
||||
<span className="cw-mctx-stage-name">{stage.name}</span>
|
||||
</div>
|
||||
<span className="cw-mctx-readonly" title="Sleep het block in de timetable om te wijzigen">vast</span>
|
||||
</div>
|
||||
<div className="cw-mctx-time">
|
||||
<div className="cw-mctx-time-col">
|
||||
<span className="cw-mctx-eyebrow">Starttijd</span>
|
||||
<span className="cw-mctx-time-val">{startStr}</span>
|
||||
</div>
|
||||
<span className="cw-mctx-arrow" aria-hidden="true">→</span>
|
||||
<div className="cw-mctx-time-col">
|
||||
<span className="cw-mctx-eyebrow">Eindtijd</span>
|
||||
<span className="cw-mctx-time-val cw-mctx-time-soft">{endStr}</span>
|
||||
</div>
|
||||
<div className="cw-mctx-time-spacer"></div>
|
||||
<span className="cw-mctx-dur">60 min</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fromTimetable && mode === "add" && (
|
||||
<>
|
||||
<Field label="Duur optreden" hint="Wordt gebruikt zodra je het block uit de wachtrij naar de timetable sleept">
|
||||
<select className="cw-input" value={duration}
|
||||
onChange={(e) => setDuration(+e.target.value)}>
|
||||
<option value={30}>30 min</option>
|
||||
<option value={45}>45 min</option>
|
||||
<option value={60}>60 min (1 uur)</option>
|
||||
<option value={75}>75 min</option>
|
||||
<option value={90}>90 min (1u30)</option>
|
||||
<option value={120}>120 min (2 uur)</option>
|
||||
<option value={150}>150 min (2u30)</option>
|
||||
<option value={180}>180 min (3 uur)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<div className="cw-modal-hint">
|
||||
Geen tijdslot gekozen — landt in de <b>wachtrij</b>.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="cw-modal-ft">
|
||||
{mode === "edit" && (
|
||||
<button className="cw-btn cw-btn-danger" onClick={() => onDelete(perf.id)}>Verwijderen</button>
|
||||
)}
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<button className="cw-btn" onClick={onClose}>Annuleren</button>
|
||||
<button className="cw-btn cw-btn-primary" onClick={save} disabled={!canSave}>
|
||||
{mode === "add" ? "Toevoegen" : "Opslaan"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stage editor ────────────────────────────────────────────────────
|
||||
function StageEditor({ stage, days, stageDays, onSave, onClose, onDelete, mode, defaultDayIds }) {
|
||||
const isCreate = mode === "create";
|
||||
const [name, setName] = React.useState(stage.name);
|
||||
const [color, setColor] = React.useState(stage.color);
|
||||
const [capacity, setCapacity] = React.useState(stage.capacity);
|
||||
const [activeDays, setActiveDays] = React.useState(
|
||||
isCreate
|
||||
? (defaultDayIds || [])
|
||||
: days.filter(d => stageDays.some(sd => sd.stage_id === stage.id && sd.day_id === d.id)).map(d => d.id)
|
||||
);
|
||||
|
||||
const SWATCHES = ["#e85d75", "#7a8af0", "#f0a04b", "#5fc9a8", "#c89af0", "#e8d05f", "#3cc2a8", "#5a8fcf", "#cf5a8f", "#8fcf5a"];
|
||||
|
||||
function toggleDay(id) {
|
||||
setActiveDays(activeDays.includes(id)
|
||||
? activeDays.filter(x => x !== id)
|
||||
: [...activeDays, id]);
|
||||
}
|
||||
function save() {
|
||||
onSave({ ...stage, name, color, capacity: +capacity }, activeDays);
|
||||
}
|
||||
|
||||
return (
|
||||
<Backdrop onClose={onClose}>
|
||||
<div className="cw-modal" style={{ width: 480 }} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="cw-modal-hd">
|
||||
<div className="cw-modal-title">
|
||||
{isCreate ? "Stage aanmaken" : "Stage bewerken"}
|
||||
<span className="cw-modal-sub">{isCreate ? "Nieuwe stage" : stage.name}</span>
|
||||
</div>
|
||||
<button className="cw-icon-btn" onClick={onClose} aria-label="Sluiten">×</button>
|
||||
</div>
|
||||
<div className="cw-modal-body">
|
||||
<Field label="Naam">
|
||||
<input className="cw-input" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Capaciteit" hint="Voor capacity-warnings op artiesten met grote draw">
|
||||
<input className="cw-input" type="number" value={capacity} onChange={(e) => setCapacity(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Kleur">
|
||||
<div className="cw-swatch-row">
|
||||
{SWATCHES.map(c => (
|
||||
<button key={c}
|
||||
className={"cw-swatch" + (c === color ? " is-active" : "")}
|
||||
style={{ background: c }}
|
||||
onClick={() => setColor(c)} />
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Actief op dagen" hint="Deze stage verschijnt alleen in de timetable van geselecteerde dagen">
|
||||
<div className="cw-day-chips">
|
||||
{days.map(d => (
|
||||
<button key={d.id}
|
||||
className={"cw-chip" + (activeDays.includes(d.id) ? " is-active" : "")}
|
||||
onClick={() => toggleDay(d.id)}>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="cw-modal-ft">
|
||||
{!isCreate && (
|
||||
<button className="cw-btn cw-btn-danger" onClick={() => onDelete(stage.id)}>Verwijder stage</button>
|
||||
)}
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<button className="cw-btn" onClick={onClose}>Annuleren</button>
|
||||
<button className="cw-btn cw-btn-primary" onClick={save}>{isCreate ? "Aanmaken" : "Opslaan"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Lineup matrix: stages × days bulk editor ────────────────────────
|
||||
function LineupMatrix({ stages, days, stageDays, onSave, onClose }) {
|
||||
// Build state: { stageId: Set<dayId> }
|
||||
const [matrix, setMatrix] = React.useState(() => {
|
||||
const m = {};
|
||||
stages.forEach(s => {
|
||||
m[s.id] = new Set(stageDays.filter(sd => sd.stage_id === s.id).map(sd => sd.day_id));
|
||||
});
|
||||
return m;
|
||||
});
|
||||
|
||||
function toggle(sid, did) {
|
||||
setMatrix(prev => {
|
||||
const next = { ...prev };
|
||||
const s = new Set(next[sid]);
|
||||
s.has(did) ? s.delete(did) : s.add(did);
|
||||
next[sid] = s;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function save() {
|
||||
const out = [];
|
||||
for (const sid in matrix) {
|
||||
for (const did of matrix[sid]) out.push({ stage_id: sid, day_id: did });
|
||||
}
|
||||
onSave(out);
|
||||
}
|
||||
|
||||
return (
|
||||
<Backdrop onClose={onClose}>
|
||||
<div className="cw-modal" style={{ width: 640 }} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="cw-modal-hd">
|
||||
<div className="cw-modal-title">
|
||||
Lineup per dag
|
||||
<span className="cw-modal-sub">Welke stages draaien op welke dag</span>
|
||||
</div>
|
||||
<button className="cw-icon-btn" onClick={onClose} aria-label="Sluiten">×</button>
|
||||
</div>
|
||||
<div className="cw-modal-body">
|
||||
<table className="cw-matrix">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Stage</th>
|
||||
{days.map(d => <th key={d.id}>{d.label}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stages.map(s => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<span className="cw-stage-dot" style={{ background: s.color }}></span>
|
||||
{s.name}
|
||||
</td>
|
||||
{days.map(d => (
|
||||
<td key={d.id} className="cw-matrix-cell">
|
||||
<button
|
||||
className={"cw-check" + (matrix[s.id].has(d.id) ? " is-checked" : "")}
|
||||
onClick={() => toggle(s.id, d.id)}
|
||||
aria-label={`Toggle ${s.name} on ${d.label}`}>
|
||||
{matrix[s.id].has(d.id) && (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12">
|
||||
<path d="M2 6 L5 9 L10 3" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="cw-hint">
|
||||
Tip: stages die op meerdere dagen draaien (zoals Silent Disco of Schirmbar)
|
||||
kun je hier in één keer instellen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="cw-modal-ft">
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<button className="cw-btn" onClick={onClose}>Annuleren</button>
|
||||
<button className="cw-btn cw-btn-primary" onClick={save}>Opslaan</button>
|
||||
</div>
|
||||
</div>
|
||||
</Backdrop>
|
||||
);
|
||||
}
|
||||
|
||||
return { PerformanceModal, StageEditor, LineupMatrix };
|
||||
})();
|
||||
|
||||
Object.assign(window, { CrewliModals: M });
|
||||
264
resources/Crewli - Artist Timetable Management/popover.jsx
Normal file
264
resources/Crewli - Artist Timetable Management/popover.jsx
Normal file
@@ -0,0 +1,264 @@
|
||||
// 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 });
|
||||
1220
resources/Crewli - Artist Timetable Management/styles.css
Normal file
1220
resources/Crewli - Artist Timetable Management/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
1133
resources/Crewli - Artist Timetable Management/timetable.jsx
Normal file
1133
resources/Crewli - Artist Timetable Management/timetable.jsx
Normal file
File diff suppressed because it is too large
Load Diff
568
resources/Crewli - Artist Timetable Management/tweaks-panel.jsx
Normal file
568
resources/Crewli - Artist Timetable Management/tweaks-panel.jsx
Normal file
@@ -0,0 +1,568 @@
|
||||
|
||||
// tweaks-panel.jsx
|
||||
// Reusable Tweaks shell + form-control helpers.
|
||||
//
|
||||
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
|
||||
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
|
||||
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
|
||||
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
|
||||
//
|
||||
// Usage (in an HTML file that loads React + Babel):
|
||||
//
|
||||
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||
// "primaryColor": "#D97757",
|
||||
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
|
||||
// "fontSize": 16,
|
||||
// "density": "regular",
|
||||
// "dark": false
|
||||
// }/*EDITMODE-END*/;
|
||||
//
|
||||
// function App() {
|
||||
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||
// return (
|
||||
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
||||
// Hello
|
||||
// <TweaksPanel>
|
||||
// <TweakSection label="Typography" />
|
||||
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
||||
// onChange={(v) => setTweak('fontSize', v)} />
|
||||
// <TweakRadio label="Density" value={t.density}
|
||||
// options={['compact', 'regular', 'comfy']}
|
||||
// onChange={(v) => setTweak('density', v)} />
|
||||
// <TweakSection label="Theme" />
|
||||
// <TweakColor label="Primary" value={t.primaryColor}
|
||||
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
|
||||
// onChange={(v) => setTweak('primaryColor', v)} />
|
||||
// <TweakColor label="Palette" value={t.palette}
|
||||
// options={[['#D97757', '#29261b', '#f6f4ef'],
|
||||
// ['#475569', '#0f172a', '#f1f5f9']]}
|
||||
// onChange={(v) => setTweak('palette', v)} />
|
||||
// <TweakToggle label="Dark mode" value={t.dark}
|
||||
// onChange={(v) => setTweak('dark', v)} />
|
||||
// </TweaksPanel>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const __TWEAKS_STYLE = `
|
||||
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
||||
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
||||
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
|
||||
background:rgba(250,249,247,.78);color:#29261b;
|
||||
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
||||
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
||||
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
||||
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
||||
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
||||
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
||||
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
||||
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
||||
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
||||
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
||||
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
||||
overflow-y:auto;overflow-x:hidden;min-height:0;
|
||||
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||
.twk-body::-webkit-scrollbar{width:8px}
|
||||
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
||||
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
||||
border:2px solid transparent;background-clip:content-box}
|
||||
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
|
||||
border:2px solid transparent;background-clip:content-box}
|
||||
.twk-row{display:flex;flex-direction:column;gap:5px}
|
||||
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
||||
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
|
||||
color:rgba(41,38,27,.72)}
|
||||
.twk-lbl>span:first-child{font-weight:500}
|
||||
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
||||
|
||||
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
||||
color:rgba(41,38,27,.45);padding:10px 0 0}
|
||||
.twk-sect:first-child{padding-top:0}
|
||||
|
||||
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
|
||||
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
||||
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
||||
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
||||
select.twk-field{padding-right:22px;
|
||||
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||||
background-repeat:no-repeat;background-position:right 8px center}
|
||||
|
||||
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
||||
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
||||
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
||||
width:14px;height:14px;border-radius:50%;background:#fff;
|
||||
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
|
||||
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||
|
||||
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
||||
background:rgba(0,0,0,.06);user-select:none}
|
||||
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
||||
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
||||
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
||||
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
||||
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
||||
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
||||
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
|
||||
overflow-wrap:anywhere}
|
||||
|
||||
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
||||
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
||||
.twk-toggle[data-on="1"]{background:#34c759}
|
||||
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
||||
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
||||
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
||||
|
||||
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
|
||||
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
|
||||
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
|
||||
user-select:none;padding-right:8px}
|
||||
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
|
||||
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
|
||||
outline:none;color:inherit;-moz-appearance:textfield}
|
||||
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
|
||||
-webkit-appearance:none;margin:0}
|
||||
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
|
||||
|
||||
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
|
||||
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
|
||||
.twk-btn:hover{background:rgba(0,0,0,.88)}
|
||||
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
|
||||
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
|
||||
|
||||
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
||||
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
|
||||
background:transparent;flex-shrink:0}
|
||||
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
||||
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
||||
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
|
||||
|
||||
.twk-chips{display:flex;gap:6px}
|
||||
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
|
||||
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
|
||||
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
|
||||
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
|
||||
.twk-chip:hover{transform:translateY(-1px);
|
||||
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
|
||||
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
|
||||
0 2px 6px rgba(0,0,0,.15)}
|
||||
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
|
||||
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
|
||||
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
|
||||
.twk-chip>span>i:first-child{box-shadow:none}
|
||||
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
|
||||
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
|
||||
`;
|
||||
|
||||
// ── useTweaks ───────────────────────────────────────────────────────────────
|
||||
// Single source of truth for tweak values. setTweak persists via the host
|
||||
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
|
||||
function useTweaks(defaults) {
|
||||
const [values, setValues] = React.useState(defaults);
|
||||
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
|
||||
// useState-style call doesn't write a "[object Object]" key into the persisted
|
||||
// JSON block.
|
||||
const setTweak = React.useCallback((keyOrEdits, val) => {
|
||||
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
||||
? keyOrEdits : { [keyOrEdits]: val };
|
||||
setValues((prev) => ({ ...prev, ...edits }));
|
||||
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
||||
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
|
||||
// can react — the parent message only reaches the host, not peers.
|
||||
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
|
||||
}, []);
|
||||
return [values, setTweak];
|
||||
}
|
||||
|
||||
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
||||
// Floating shell. Registers the protocol listener BEFORE announcing
|
||||
// availability — if the announce ran first, the host's activate could land
|
||||
// before our handler exists and the toolbar toggle would silently no-op.
|
||||
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
|
||||
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
|
||||
// is what actually hides the panel.
|
||||
function TweaksPanel({ title = 'Tweaks', noDeckControls = false, children }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const dragRef = React.useRef(null);
|
||||
// Auto-inject a rail toggle when a <deck-stage> is on the page. The
|
||||
// toggle drives the deck's per-viewer _railVisible via window message;
|
||||
// state is mirrored from the same localStorage key the deck reads so
|
||||
// the control reflects reality across reloads. The mechanism is the
|
||||
// message — authors who want custom placement can post it directly
|
||||
// and pass noDeckControls to suppress this one.
|
||||
const hasDeckStage = React.useMemo(
|
||||
() => typeof document !== 'undefined' && !!document.querySelector('deck-stage'),
|
||||
[],
|
||||
);
|
||||
// Hide the toggle until the host has actually enabled the rail (the
|
||||
// __omelette_rail_enabled window message, posted only when the
|
||||
// omelette_deck_rail_enabled flag is on for this user). The initial read
|
||||
// covers TweaksPanel mounting after the message already arrived; the
|
||||
// listener covers the common case of mounting first.
|
||||
const [railEnabled, setRailEnabled] = React.useState(
|
||||
() => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled,
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (!hasDeckStage || railEnabled) return undefined;
|
||||
const onMsg = (e) => {
|
||||
if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true);
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
return () => window.removeEventListener('message', onMsg);
|
||||
}, [hasDeckStage, railEnabled]);
|
||||
const [railVisible, setRailVisible] = React.useState(() => {
|
||||
try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; }
|
||||
});
|
||||
const toggleRail = (on) => {
|
||||
setRailVisible(on);
|
||||
window.postMessage({ type: '__deck_rail_visible', on }, '*');
|
||||
};
|
||||
const offsetRef = React.useRef({ x: 16, y: 16 });
|
||||
const PAD = 16;
|
||||
|
||||
const clampToViewport = React.useCallback(() => {
|
||||
const panel = dragRef.current;
|
||||
if (!panel) return;
|
||||
const w = panel.offsetWidth, h = panel.offsetHeight;
|
||||
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
||||
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
||||
offsetRef.current = {
|
||||
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
||||
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
||||
};
|
||||
panel.style.right = offsetRef.current.x + 'px';
|
||||
panel.style.bottom = offsetRef.current.y + 'px';
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
clampToViewport();
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', clampToViewport);
|
||||
return () => window.removeEventListener('resize', clampToViewport);
|
||||
}
|
||||
const ro = new ResizeObserver(clampToViewport);
|
||||
ro.observe(document.documentElement);
|
||||
return () => ro.disconnect();
|
||||
}, [open, clampToViewport]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onMsg = (e) => {
|
||||
const t = e?.data?.type;
|
||||
if (t === '__activate_edit_mode') setOpen(true);
|
||||
else if (t === '__deactivate_edit_mode') setOpen(false);
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||||
return () => window.removeEventListener('message', onMsg);
|
||||
}, []);
|
||||
|
||||
const dismiss = () => {
|
||||
setOpen(false);
|
||||
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
|
||||
};
|
||||
|
||||
const onDragStart = (e) => {
|
||||
const panel = dragRef.current;
|
||||
if (!panel) return;
|
||||
const r = panel.getBoundingClientRect();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const startRight = window.innerWidth - r.right;
|
||||
const startBottom = window.innerHeight - r.bottom;
|
||||
const move = (ev) => {
|
||||
offsetRef.current = {
|
||||
x: startRight - (ev.clientX - sx),
|
||||
y: startBottom - (ev.clientY - sy),
|
||||
};
|
||||
clampToViewport();
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener('mousemove', move);
|
||||
window.removeEventListener('mouseup', up);
|
||||
};
|
||||
window.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', up);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<>
|
||||
<style>{__TWEAKS_STYLE}</style>
|
||||
<div ref={dragRef} className="twk-panel" data-noncommentable=""
|
||||
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||||
<div className="twk-hd" onMouseDown={onDragStart}>
|
||||
<b>{title}</b>
|
||||
<button className="twk-x" aria-label="Close tweaks"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={dismiss}>✕</button>
|
||||
</div>
|
||||
<div className="twk-body">
|
||||
{children}
|
||||
{hasDeckStage && railEnabled && !noDeckControls && (
|
||||
<TweakSection label="Deck">
|
||||
<TweakToggle label="Thumbnail rail" value={railVisible} onChange={toggleRail} />
|
||||
</TweakSection>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function TweakSection({ label, children }) {
|
||||
return (
|
||||
<>
|
||||
<div className="twk-sect">{label}</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakRow({ label, value, children, inline = false }) {
|
||||
return (
|
||||
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||||
<div className="twk-lbl">
|
||||
<span>{label}</span>
|
||||
{value != null && <span className="twk-val">{value}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Controls ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||||
return (
|
||||
<TweakRow label={label} value={`${value}${unit}`}>
|
||||
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
||||
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakToggle({ label, value, onChange }) {
|
||||
return (
|
||||
<div className="twk-row twk-row-h">
|
||||
<div className="twk-lbl"><span>{label}</span></div>
|
||||
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
||||
role="switch" aria-checked={!!value}
|
||||
onClick={() => onChange(!value)}><i /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakRadio({ label, value, options, onChange }) {
|
||||
const trackRef = React.useRef(null);
|
||||
const [dragging, setDragging] = React.useState(false);
|
||||
// The active value is read by pointer-move handlers attached for the lifetime
|
||||
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
||||
const valueRef = React.useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
// Segments wrap mid-word once per-segment width runs out. The track is
|
||||
// ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
|
||||
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
|
||||
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
|
||||
// back to a dropdown rather than wrap.
|
||||
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
|
||||
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
|
||||
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
|
||||
if (!fitsAsSegments) {
|
||||
// <select> emits strings — map back to the original option value so the
|
||||
// fallback stays type-preserving (numbers, booleans) like the segment path.
|
||||
const resolve = (s) => {
|
||||
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
|
||||
return m === undefined ? s : typeof m === 'object' ? m.value : m;
|
||||
};
|
||||
return <TweakSelect label={label} value={value} options={options}
|
||||
onChange={(s) => onChange(resolve(s))} />;
|
||||
}
|
||||
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
||||
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
||||
const n = opts.length;
|
||||
|
||||
const segAt = (clientX) => {
|
||||
const r = trackRef.current.getBoundingClientRect();
|
||||
const inner = r.width - 4;
|
||||
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
||||
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
||||
};
|
||||
|
||||
const onPointerDown = (e) => {
|
||||
setDragging(true);
|
||||
const v0 = segAt(e.clientX);
|
||||
if (v0 !== valueRef.current) onChange(v0);
|
||||
const move = (ev) => {
|
||||
if (!trackRef.current) return;
|
||||
const v = segAt(ev.clientX);
|
||||
if (v !== valueRef.current) onChange(v);
|
||||
};
|
||||
const up = () => {
|
||||
setDragging(false);
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
};
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
};
|
||||
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
||||
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||||
<div className="twk-seg-thumb"
|
||||
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
||||
width: `calc((100% - 4px) / ${n})` }} />
|
||||
{opts.map((o) => (
|
||||
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakSelect({ label, value, options, onChange }) {
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map((o) => {
|
||||
const v = typeof o === 'object' ? o.value : o;
|
||||
const l = typeof o === 'object' ? o.label : o;
|
||||
return <option key={v} value={v}>{l}</option>;
|
||||
})}
|
||||
</select>
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakText({ label, value, placeholder, onChange }) {
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
|
||||
const clamp = (n) => {
|
||||
if (min != null && n < min) return min;
|
||||
if (max != null && n > max) return max;
|
||||
return n;
|
||||
};
|
||||
const startRef = React.useRef({ x: 0, val: 0 });
|
||||
const onScrubStart = (e) => {
|
||||
e.preventDefault();
|
||||
startRef.current = { x: e.clientX, val: value };
|
||||
const decimals = (String(step).split('.')[1] || '').length;
|
||||
const move = (ev) => {
|
||||
const dx = ev.clientX - startRef.current.x;
|
||||
const raw = startRef.current.val + dx * step;
|
||||
const snapped = Math.round(raw / step) * step;
|
||||
onChange(clamp(Number(snapped.toFixed(decimals))));
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener('pointermove', move);
|
||||
window.removeEventListener('pointerup', up);
|
||||
};
|
||||
window.addEventListener('pointermove', move);
|
||||
window.addEventListener('pointerup', up);
|
||||
};
|
||||
return (
|
||||
<div className="twk-num">
|
||||
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
||||
<input type="number" value={value} min={min} max={max} step={step}
|
||||
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
||||
{unit && <span className="twk-num-unit">{unit}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
|
||||
// read on both #111 and #fafafa without per-option configuration. Hex input
|
||||
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
|
||||
function __twkIsLight(hex) {
|
||||
const h = String(hex).replace('#', '');
|
||||
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
|
||||
const n = parseInt(x.slice(0, 6), 16);
|
||||
if (Number.isNaN(n)) return true;
|
||||
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
||||
return r * 299 + g * 587 + b * 114 > 148000;
|
||||
}
|
||||
|
||||
const __TwkCheck = ({ light }) => (
|
||||
<svg viewBox="0 0 14 14" aria-hidden="true">
|
||||
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// TweakColor — curated color/palette picker. Each option is either a single
|
||||
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
|
||||
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
|
||||
// rest stacked in a sharp column on the right. onChange emits the
|
||||
// option in the shape it was passed (string stays string, array stays array).
|
||||
// Without options it falls back to the native color input for back-compat.
|
||||
function TweakColor({ label, value, options, onChange }) {
|
||||
if (!options || !options.length) {
|
||||
return (
|
||||
<div className="twk-row twk-row-h">
|
||||
<div className="twk-lbl"><span>{label}</span></div>
|
||||
<input type="color" className="twk-swatch" value={value}
|
||||
onChange={(e) => onChange(e.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Native <input type=color> emits lowercase hex per the HTML spec, so
|
||||
// compare case-insensitively. String() guards JSON.stringify(undefined),
|
||||
// which returns the primitive undefined (no .toLowerCase).
|
||||
const key = (o) => String(JSON.stringify(o)).toLowerCase();
|
||||
const cur = key(value);
|
||||
return (
|
||||
<TweakRow label={label}>
|
||||
<div className="twk-chips" role="radiogroup">
|
||||
{options.map((o, i) => {
|
||||
const colors = Array.isArray(o) ? o : [o];
|
||||
const [hero, ...rest] = colors;
|
||||
const sup = rest.slice(0, 4);
|
||||
const on = key(o) === cur;
|
||||
return (
|
||||
<button key={i} type="button" className="twk-chip" role="radio"
|
||||
aria-checked={on} data-on={on ? '1' : '0'}
|
||||
aria-label={colors.join(', ')} title={colors.join(' · ')}
|
||||
style={{ background: hero }}
|
||||
onClick={() => onChange(o)}>
|
||||
{sup.length > 0 && (
|
||||
<span>
|
||||
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
|
||||
</span>
|
||||
)}
|
||||
{on && <__TwkCheck light={__twkIsLight(hero)} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TweakRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TweakButton({ label, onClick, secondary = false }) {
|
||||
return (
|
||||
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
||||
onClick={onClick}>{label}</button>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
||||
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||
TweakText, TweakNumber, TweakColor, TweakButton,
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 499 KiB |
Reference in New Issue
Block a user