Add design en information for developing the Artist Management module

This commit is contained in:
2026-05-08 16:57:03 +02:00
parent a57437a4b7
commit c9863ee4f8
14 changed files with 7068 additions and 1 deletions

View 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 });