380 lines
16 KiB
JavaScript
380 lines
16 KiB
JavaScript
// 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 });
|