// Rotatory – Frontend ohne Abhängigkeiten. Alle Pfade relativ (Proxy-tauglich). const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const state = { data: null, month: startOfMonth(new Date()) }; // ---------- Hilfen ---------- function pad(n) { return String(n).padStart(2, '0'); } function iso(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } function startOfMonth(d) { return new Date(d.getFullYear(), d.getMonth(), 1); } function monthKey(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; } function monthRange(fromKey, toKey) { const [fy, fm] = fromKey.split('-').map(Number); const [ty, tm] = toKey.split('-').map(Number); return { from: `${fy}-${pad(fm)}-01`, to: iso(new Date(ty, tm, 0)) }; } function todayIso() { return iso(new Date()); } function fmtDate(isoStr) { const [y, m, d] = isoStr.split('-'); return `${d}.${m}.${y}`; } function fmtLong(isoStr) { return new Date(isoStr + 'T12:00:00').toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); } const LOCK_ICON = ''; const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; let toastTimer; function toast(msg, isError = false) { const el = $('#toast'); el.textContent = msg; el.classList.toggle('error', isError); el.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.hidden = true; }, isError ? 6000 : 3000); } async function api(path, { method = 'GET', body, raw } = {}) { const opts = { method, headers: {} }; if (raw) { opts.body = raw.body; opts.headers['Content-Type'] = raw.type; } else if (body !== undefined) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; } const res = await fetch(path, opts); if (res.status === 204) return null; const data = await res.json().catch(() => ({})); if (!res.ok) { if (data.authRequired) { openLogin(); throw new Error('Zum Bearbeiten bitte anmelden.'); } throw new Error(data.error || `HTTP ${res.status}`); } return data; } // ---------- Anmeldung zum Bearbeiten ---------- const EDITOR_VIEWS = ['personen', 'abwesenheiten', 'einstellungen']; function isEditor() { return !state.data || state.data.auth?.editor !== false; } function applyAuthUI() { const a = state.data?.auth || { protected: false, editor: true }; document.body.classList.toggle('viewer', !a.editor); const btn = $('#auth-button'); btn.hidden = !a.protected; btn.textContent = a.editor ? 'Abmelden' : 'Anmelden'; for (const link of $$('.topbar nav a')) link.hidden = !a.editor && EDITOR_VIEWS.includes(link.dataset.view); $('#open-plan').hidden = !a.editor; $('#open-nextcloud').hidden = !a.editor; if (!a.editor && EDITOR_VIEWS.includes(location.hash.slice(1))) location.hash = '#kalender'; } function openLogin() { const dlg = $('#login-dialog'); if (dlg.open) return; $('#login-result').hidden = true; $('#login-password').value = ''; dlg.showModal(); } $('#auth-button').addEventListener('click', async () => { if (isEditor()) { try { await api('api/auth/logout', { method: 'POST' }); await load(); toast('Abgemeldet. Du siehst jetzt die Ansicht für das Team.'); } catch (err) { toast(err.message, true); } } else openLogin(); }); $('#login-close').addEventListener('click', () => $('#login-dialog').close()); $('#login-form').addEventListener('submit', async (e) => { e.preventDefault(); const out = $('#login-result'); try { await api('api/auth/login', { method: 'POST', body: { password: $('#login-password').value } }); $('#login-dialog').close(); await load(); toast('Angemeldet. Bearbeiten ist freigeschaltet.'); } catch (err) { out.textContent = err.message; out.hidden = false; } }); async function load() { state.data = await api('api/state'); renderAll(); } /** Aktion ausführen, Zustand neu laden, Fehler als Hinweis zeigen. */ async function run(action, okMessage) { try { await action(); await load(); if (okMessage) toast(okMessage); } catch (err) { toast(err.message, true); } } function personById(id) { return state.data.persons.find((p) => p.id === id); } function fullName(p) { return p ? `${p.firstName} ${p.lastName}` : 'unbesetzt'; } function shortName(p) { return p ? `${p.firstName} ${p.lastName.charAt(0)}.` : '?'; } function initials(p) { return `${p.firstName.charAt(0)}${p.lastName.charAt(0)}`.toUpperCase(); } function isAbsent(personId, date) { return state.data.absences.some((a) => a.personId === personId && a.from <= date && a.to >= date); } function holidayFor(date) { return state.data.holidays.find((h) => h.date === date); } function avatarEl(p, cls = '') { if (!p) { const e = document.createElement('span'); e.className = `avatar empty ${cls}`; e.textContent = '?'; e.setAttribute('aria-label', 'unbesetzt'); return e; } if (p.hasPhoto) { const img = document.createElement('img'); img.className = `avatar ${cls}`; img.src = `photos/${p.id}?v=${p.photoVersion || 0}`; img.alt = fullName(p); img.loading = 'lazy'; return img; } const e = document.createElement('span'); e.className = `avatar ${cls}`; e.textContent = initials(p); e.setAttribute('role', 'img'); e.setAttribute('aria-label', fullName(p)); return e; } // ---------- Navigation ---------- function showView(name) { const known = ['kalender', 'personen', 'abwesenheiten', 'einstellungen']; if (!known.includes(name)) name = 'kalender'; if (!isEditor() && EDITOR_VIEWS.includes(name)) name = 'kalender'; if (name === 'abwesenheiten' && state.data) prepareAbsenceImport(); for (const v of $$('.view')) v.hidden = v.id !== `view-${name}`; for (const a of $$('.topbar nav a')) { if (a.dataset.view === name) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); } } window.addEventListener('hashchange', () => showView(location.hash.slice(1))); // ---------- Kalender ---------- function renderCalendar() { const d = state.month; $('#cal-title').textContent = `${MONTHS[d.getMonth()]} ${d.getFullYear()}`; const grid = $('#cal-grid'); grid.textContent = ''; const { assignments, persons } = state.data; const today = todayIso(); const first = new Date(d.getFullYear(), d.getMonth(), 1); const last = new Date(d.getFullYear(), d.getMonth() + 1, 0); // Auf Montag der ersten Woche zurückgehen const start = new Date(first); const offset = (first.getDay() + 6) % 7; start.setDate(first.getDate() - offset); let planned = 0; let unfilled = 0; for (let cur = new Date(start); cur <= last; cur.setDate(cur.getDate() + 1)) { if (cur.getDay() === 0 || cur.getDay() === 6) continue; const date = iso(cur); const outside = cur.getMonth() !== d.getMonth(); const editable = !outside && isEditor(); const cell = document.createElement(editable ? 'button' : 'div'); cell.className = 'day'; if (editable) { cell.type = 'button'; cell.dataset.date = date; } if (outside) cell.classList.add('outside'); if (date === today) cell.classList.add('today'); if (date < today) cell.classList.add('past'); const num = document.createElement('div'); num.className = 'num'; const numText = document.createElement('span'); numText.textContent = cur.getDate(); num.appendChild(numText); cell.appendChild(num); if (outside) { grid.appendChild(cell); continue; } const hol = holidayFor(date); const a = assignments[date]; if (hol) { cell.classList.add('holiday'); const l = document.createElement('div'); l.className = 'label'; l.textContent = hol.name; cell.appendChild(l); cell.setAttribute('aria-label', `${fmtLong(date)}, ${hol.name}`); } else if (a && a.off) { const l = document.createElement('div'); l.className = 'label off'; l.textContent = 'Kein Meeting'; cell.appendChild(l); cell.setAttribute('aria-label', `${fmtLong(date)}, kein Meeting`); } else if (a) { planned += 1; if (a.locked) { const lock = document.createElement('span'); lock.className = 'lock'; lock.innerHTML = LOCK_ICON; lock.title = 'fixiert'; num.appendChild(lock); } const pair = document.createElement('div'); pair.className = 'pair'; const main = personById(a.main); const co = personById(a.co); if (!main || !co) unfilled += 1; pair.appendChild(slotEl(main, 'main', date)); pair.appendChild(slotEl(co, 'co', date)); cell.appendChild(pair); const absentNote = (p) => (p && isAbsent(p.id, date) ? ' (abwesend)' : ''); cell.setAttribute('aria-label', `${fmtLong(date)}: Haupt ${fullName(main)}${absentNote(main)}, Neben ${fullName(co)}${absentNote(co)}${a.locked ? ', fixiert' : ''}`); } else { cell.setAttribute('aria-label', `${fmtLong(date)}, noch nicht ausgelost`); } grid.appendChild(cell); } const hint = $('#cal-hint'); if (persons.length === 0) { hint.textContent = 'Noch keine Personen angelegt – zuerst unter „Personen" die Belegschaft anlegen.'; hint.hidden = false; } else if (planned === 0) { hint.textContent = 'Für diesen Monat ist noch nichts ausgelost. Mit „Auslosen …" starten.'; hint.hidden = false; } else if (unfilled > 0) { hint.textContent = `${unfilled} Tag(e) sind nicht vollständig besetzt – im Kalender anklicken und manuell zuweisen oder neu auslosen.`; hint.hidden = false; } else hint.hidden = true; const range = monthRange(monthKey(d), monthKey(d)); $('#download-ics').href = `api/export.ics?from=${range.from}&to=${range.to}`; renderStats(range); } function slotEl(p, role, date) { const slot = document.createElement('div'); slot.className = 'slot'; const wrap = document.createElement('span'); wrap.className = 'avatar-wrap'; const absent = Boolean(p && isAbsent(p.id, date)); wrap.appendChild(avatarEl(p, `${role === 'co' ? 'co' : ''}${absent ? ' absent' : ''}`)); const badge = document.createElement('span'); badge.className = `badge ${role}`; badge.textContent = role === 'main' ? 'H' : 'N'; badge.setAttribute('aria-hidden', 'true'); wrap.appendChild(badge); slot.appendChild(wrap); const name = document.createElement('span'); name.className = 'name'; name.textContent = shortName(p); name.title = fullName(p); if (absent) { name.classList.add('absent'); name.title += ' – abwesend'; } slot.appendChild(name); return slot; } function renderStats(range) { const counts = {}; for (const [date, a] of Object.entries(state.data.assignments)) { if (date < range.from || date > range.to || a.off) continue; for (const [role, id] of [['main', a.main], ['co', a.co]]) { if (!id) continue; counts[id] = counts[id] || { main: 0, co: 0 }; counts[id][role] += 1; } } const box = $('#stats'); box.textContent = ''; const rows = state.data.persons .filter((p) => p.active !== false && (p.canMain || p.canCo || counts[p.id])) .map((p) => ({ p, c: counts[p.id] || { main: 0, co: 0 } })) .sort((a, b) => (b.c.main + b.c.co) - (a.c.main + a.c.co) || fullName(a.p).localeCompare(fullName(b.p))); if (rows.length === 0) { box.innerHTML = 'Keine aktiven Moderatoren.'; return; } for (const { p, c } of rows) { const chip = document.createElement('span'); chip.className = 'chip'; chip.appendChild(avatarEl(p, 'plain')); const t = document.createElement('span'); t.textContent = shortName(p); chip.appendChild(t); const n = document.createElement('span'); n.className = 'count'; n.textContent = `${c.main + c.co}× (H ${c.main} · N ${c.co})`; chip.appendChild(n); box.appendChild(chip); } } $('#prev-month').addEventListener('click', () => { state.month = new Date(state.month.getFullYear(), state.month.getMonth() - 1, 1); renderCalendar(); }); $('#next-month').addEventListener('click', () => { state.month = new Date(state.month.getFullYear(), state.month.getMonth() + 1, 1); renderCalendar(); }); $('#today-month').addEventListener('click', () => { state.month = startOfMonth(new Date()); renderCalendar(); }); $('#cal-grid').addEventListener('click', (e) => { const cell = e.target.closest('.day[data-date]'); if (cell) openDayDialog(cell.dataset.date); }); // ---------- Tag-Dialog ---------- const dayDialog = $('#day-dialog'); const dayForm = $('#day-form'); let dayDialogDate = null; function fillPersonSelect(select, role, date, selected) { select.textContent = ''; const none = document.createElement('option'); none.value = ''; none.textContent = '– unbesetzt –'; select.appendChild(none); for (const p of state.data.persons.filter((x) => x.active !== false).sort((a, b) => fullName(a).localeCompare(fullName(b)))) { const o = document.createElement('option'); o.value = p.id; const flags = []; if (role === 'main' && !p.canMain) flags.push('keine Hauptmoderation'); if (role === 'co' && !p.canCo) flags.push('keine Nebenmoderation'); if (isAbsent(p.id, date)) flags.push('abwesend'); o.textContent = fullName(p) + (flags.length ? ` (${flags.join(', ')})` : ''); select.appendChild(o); } select.value = selected || ''; } function openDayDialog(date) { dayDialogDate = date; const a = state.data.assignments[date] || {}; const hol = holidayFor(date); $('#day-dialog-title').textContent = fmtLong(date); $('#day-dialog-info').textContent = hol ? `${hol.name} – an diesem Tag findet kein Meeting statt. Der Feiertag lässt sich unter Einstellungen entfernen.` : ''; fillPersonSelect(dayForm.elements.main, 'main', date, a.main); fillPersonSelect(dayForm.elements.co, 'co', date, a.co); dayForm.elements.locked.checked = Boolean(a.locked); dayForm.elements.off.checked = Boolean(a.off); for (const el of [dayForm.elements.main, dayForm.elements.co, dayForm.elements.locked, dayForm.elements.off, $('#day-reroll'), $('#day-clear'), dayForm.querySelector('button[type="submit"]')]) el.disabled = Boolean(hol); dayDialog.showModal(); } dayForm.addEventListener('submit', async (e) => { e.preventDefault(); const f = dayForm.elements; try { await api(`api/assignments/${dayDialogDate}`, { method: 'PUT', body: { main: f.main.value || null, co: f.co.value || null, locked: f.locked.checked, off: f.off.checked } }); dayDialog.close(); await load(); toast('Tag gespeichert.'); } catch (err) { toast(err.message, true); } }); $('#day-close').addEventListener('click', () => dayDialog.close()); $('#day-reroll').addEventListener('click', async () => { try { // Nur ein Vorschlag – gespeichert wird erst mit „Speichern" const a = await api(`api/assignments/${dayDialogDate}/reroll`, { method: 'POST', body: { preview: true } }); dayForm.elements.main.value = a.main || ''; dayForm.elements.co.value = a.co || ''; dayForm.elements.off.checked = false; toast('Vorschlag ausgelost – mit „Speichern" übernehmen.'); } catch (err) { toast(err.message, true); } }); $('#day-clear').addEventListener('click', async () => { try { await api(`api/assignments/${dayDialogDate}`, { method: 'PUT', body: { main: null, co: null, locked: false, off: false } }); dayDialog.close(); await load(); } catch (err) { toast(err.message, true); } }); // ---------- Auslosen-Dialog ---------- const planDialog = $('#plan-dialog'); const planForm = $('#plan-form'); $('#open-plan').addEventListener('click', () => { const next = new Date(state.month.getFullYear(), state.month.getMonth() + 2, 1); planForm.elements.from.value = monthKey(state.month); planForm.elements.to.value = monthKey(next); $('#plan-result').hidden = true; planDialog.showModal(); }); $('#plan-close').addEventListener('click', () => planDialog.close()); planForm.addEventListener('submit', async (e) => { e.preventDefault(); const r = monthRange(planForm.elements.from.value, planForm.elements.to.value); const out = $('#plan-result'); try { const res = await api('api/schedule/generate', { method: 'POST', body: { ...r, overwrite: planForm.elements.overwrite.checked } }); await load(); out.className = 'result'; out.textContent = `${res.planned} Werktage geplant.` + (res.unfilled.length ? `\n${res.unfilled.length} Tag(e) konnten nicht voll besetzt werden: ${res.unfilled.map(fmtDate).join(', ')}` : ' Alle Tage besetzt.'); out.hidden = false; } catch (err) { out.className = 'result error'; out.textContent = err.message; out.hidden = false; } }); $('#plan-clear').addEventListener('click', async () => { const r = monthRange(planForm.elements.from.value, planForm.elements.to.value); if (!confirm(`Alle nicht fixierten Zuteilungen von ${fmtDate(r.from)} bis ${fmtDate(r.to)} löschen?`)) return; try { await api('api/schedule/clear', { method: 'POST', body: r }); await load(); $('#plan-result').className = 'result'; $('#plan-result').textContent = 'Zeitraum geleert.'; $('#plan-result').hidden = false; } catch (err) { toast(err.message, true); } }); // ---------- Nextcloud: Kalenderliste ---------- let calendarsPromise = null; function loadCalendars(force = false) { if (!calendarsPromise || force) calendarsPromise = api('api/nextcloud/calendars').catch((err) => { calendarsPromise = null; throw err; }); return calendarsPromise; } function calendarLabel(c) { // Nextcloud hängt bei geteilten Kalendern den Besitzer schon an den Namen an return `${c.name}${c.writable ? '' : ' – nur lesen'}`; } /** Select mit Kalendern füllen; onlyWritable filtert, extra fügt eine Option vorne an. */ async function fillCalendarSelect(select, { selected = '', onlyWritable = false, extra = null } = {}) { select.textContent = ''; if (!state.data.nextcloud.configured) { select.appendChild(new Option('Nextcloud nicht konfiguriert', '')); select.disabled = true; return []; } select.disabled = true; select.appendChild(new Option('Lade Kalender …', '')); try { const { calendars } = await loadCalendars(); select.textContent = ''; if (extra) select.appendChild(new Option(extra.label, extra.value)); const list = calendars.filter((c) => !onlyWritable || c.writable); for (const c of list) select.appendChild(new Option(calendarLabel(c), c.href)); if (selected && list.some((c) => c.href === selected)) select.value = selected; else if (!extra && list.length) select.value = list[0].href; select.disabled = false; return list; } catch (err) { select.textContent = ''; select.appendChild(new Option(`Fehler: ${err.message}`, '')); return []; } } function selectedCalendarName(select) { return select.selectedOptions[0]?.textContent || ''; } // ---------- Nextcloud-Dialog ---------- const ncDialog = $('#nextcloud-dialog'); const ncForm = $('#nextcloud-form'); $('#open-nextcloud').addEventListener('click', async () => { const nc = state.data.nextcloud; const next = new Date(state.month.getFullYear(), state.month.getMonth() + 2, 1); ncForm.elements.from.value = monthKey(state.month); ncForm.elements.to.value = monthKey(next); ncForm.elements.removeForeign.checked = false; $('#nextcloud-info').textContent = nc.configured ? `${nc.baseUrl} · angemeldet als ${nc.user}` : 'Nextcloud ist nicht konfiguriert. NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_APP_PASSWORD als Umgebungsvariablen setzen.'; $('#nextcloud-go').disabled = !nc.configured; $('#nextcloud-test').disabled = !nc.configured; $('#nextcloud-result').hidden = true; ncDialog.showModal(); const list = await fillCalendarSelect(ncForm.elements.target, { selected: state.data.settings.targetCalendarHref, onlyWritable: true, extra: { label: `Neuen Kalender „${state.data.settings.calendarDisplayName}" anlegen (${nc.calendar})`, value: '' }, }); // Noch kein Ziel gewählt: vorhandenen Morning-Meeting-Kalender vorschlagen if (!state.data.settings.targetCalendarHref) { const guess = list.find((c) => /morning|meeting|moderation/i.test(c.name)); if (guess) ncForm.elements.target.value = guess.href; } }); ncForm.elements.target.addEventListener('change', async () => { const sel = ncForm.elements.target; try { await api('api/settings', { method: 'PUT', body: { targetCalendarHref: sel.value, targetCalendarName: sel.value ? selectedCalendarName(sel) : '' } }); state.data.settings.targetCalendarHref = sel.value; state.data.settings.targetCalendarName = sel.value ? selectedCalendarName(sel) : ''; renderSettings(); } catch (err) { toast(err.message, true); } }); $('#nextcloud-close').addEventListener('click', () => ncDialog.close()); $('#nextcloud-test').addEventListener('click', async () => { const out = $('#nextcloud-result'); const btn = $('#nextcloud-test'); btn.disabled = true; try { const r = await api('api/nextcloud/test', { method: 'POST' }); out.className = 'result'; const writable = r.calendars.filter((c) => c.writable).length; out.textContent = `Verbindung OK – angemeldet als ${r.displayName || r.userId} (ID ${r.userId}).\n${r.calendars.length} Kalender sichtbar, ${writable} davon beschreibbar.`; await fillCalendarSelect(ncForm.elements.target, { selected: ncForm.elements.target.value || state.data.settings.targetCalendarHref, onlyWritable: true, extra: { label: `Neuen Kalender „${state.data.settings.calendarDisplayName}" anlegen`, value: '' } }); } catch (err) { out.className = 'result error'; out.textContent = err.message; } out.hidden = false; btn.disabled = false; }); ncForm.addEventListener('submit', async (e) => { e.preventDefault(); const r = monthRange(ncForm.elements.from.value, ncForm.elements.to.value); const out = $('#nextcloud-result'); const btn = $('#nextcloud-go'); btn.disabled = true; out.className = 'result'; out.textContent = 'Schreibe Termine …'; out.hidden = false; try { const sel = ncForm.elements.target; const res = await api('api/nextcloud/sync', { method: 'POST', body: { ...r, removeForeign: ncForm.elements.removeForeign.checked, targetCalendarHref: sel.value, targetCalendarName: sel.value ? selectedCalendarName(sel) : '' } }); await load(); const parts = [`${res.created ? 'Kalender angelegt. ' : ''}${res.written} Termin(e) geschrieben, ${res.removed} entfernt.`]; if (res.moved) parts.push(`${res.moved} Termin(e) aus dem vorherigen Zielkalender entfernt.`); if (res.foreignRemoved) parts.push(`${res.foreignRemoved} fremde Termin(e) im Zeitraum gelöscht.`); else if (res.foreign) parts.push(`Hinweis: ${res.foreign} fremde Termin(e) im Zeitraum vorhanden (nicht entfernt).`); if (res.foreignRecurring) parts.push(`${res.foreignRecurring} wiederkehrende fremde Serie(n) bleiben unangetastet – bei Bedarf in Nextcloud löschen.`); if (res.errors.length) parts.push(`Fehler:\n${res.errors.join('\n')}`); out.textContent = parts.join('\n'); if (res.errors.length) out.className = 'result error'; } catch (err) { out.className = 'result error'; out.textContent = err.message; } btn.disabled = false; }); // ---------- Personen ---------- const personForm = $('#person-form'); const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr']; function renderPersons() { const list = $('#person-list'); list.textContent = ''; const persons = [...state.data.persons].sort((a, b) => fullName(a).localeCompare(fullName(b))); if (persons.length === 0) { list.innerHTML = '
Bearbeitung ist durch das Passwort aus EDIT_PASSWORD geschützt. Das Team sieht nur den Kalender.
Kein Bearbeitungspasswort gesetzt: Jeder, der die Seite öffnet, kann auch bearbeiten. Zum Schützen EDIT_PASSWORD als Umgebungsvariable setzen.
Konfiguriert: ${escapeHtml(nc.baseUrl)}, angemeldet als ${escapeHtml(nc.user)}.
Zielkalender: ${s.targetCalendarHref ? escapeHtml(s.targetCalendarName || s.targetCalendarHref) : `neuer Kalender „${escapeHtml(s.calendarDisplayName)}" (${escapeHtml(nc.calendar)})`} – wählbar im Dialog „Nextcloud …" im Kalender.
` + `Quellkalender für Abwesenheiten: ${s.absenceCalendarHref ? escapeHtml(s.absenceCalendarName || s.absenceCalendarHref) : 'noch keiner'} – wählbar unter „Abwesenheiten".
` : `Nicht konfiguriert.
Umgebungsvariablen setzen: NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_APP_PASSWORD (App-Passwort aus Nextcloud → Einstellungen → Sicherheit), optional NEXTCLOUD_CALENDAR. Bis dahin steht der ICS-Download zur Verfügung.