Files
Rotatory/public/app.js
T
ferdi2goandClaude Fable 5.1 42d495c54e Tauschanfragen gegen offene Plätze erlauben und Lücken nachbesetzen
Ein Wunschtag ohne Tauschpartner bedeutet jetzt: die Person übernimmt
dort einen offenen Platz; ihr alter Platz wird beim Ausführen sofort
fair nachbesetzt, ohne sie selbst. Rollen werden bei allen Tauschformen
geprüft. Bearbeiter können Person und Wunschtag einer Anfrage nachtragen,
etwa aus der Notiz. Der Anfragedialog zeigt, was am Wunschtag los ist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 11:55:53 +00:00

1525 lines
73 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="1.5"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>';
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', 'anfragen', 'bugs', '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;
$('#open-request').hidden = a.editor;
const open = (state.data?.requests || []).filter((r) => r.status === 'offen').length;
const badge = $('#requests-count');
badge.hidden = !a.editor || open === 0;
badge.textContent = open;
const openBugs = (state.data?.bugs || []).filter((b) => b.status === 'offen').length;
const bugBadge = $('#bugs-count');
bugBadge.hidden = !a.editor || openBugs === 0;
bugBadge.textContent = openBugs;
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', 'anfragen', 'bugs', '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 assigned = assignments[date] && !assignments[date].off && !holidayFor(date) && (assignments[date].main || assignments[date].co);
const editable = !outside && (isEditor() || Boolean(assigned));
const cell = document.createElement(editable ? 'button' : 'div');
cell.className = 'day';
if (editable) { cell.type = 'button'; cell.dataset.date = date; }
if (!isEditor() && assigned) cell.classList.add('requestable');
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);
const hasRequest = !outside && (state.data.requestDates || []).includes(date);
if (hasRequest) { const m = document.createElement('span'); m.className = 'req-mark'; m.title = 'Tauschanfrage offen'; num.appendChild(m); }
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' : ''}${hasRequest ? ', Tauschanfrage offen' : ''}`);
} 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 = '<span class="hint">Keine aktiven Moderatoren.</span>'; 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) return;
if (isEditor()) openDayDialog(cell.dataset.date); else openRequestDialog(cell.dataset.date);
});
// ---------- Tauschanfrage (für alle) ----------
const requestDialog = $('#request-dialog');
const requestForm = $('#request-form');
function fillRequestPersons(date) {
const f = requestForm.elements;
const a = state.data.assignments[date] || {};
const active = state.data.persons.filter((p) => p.active !== false && (p.canMain || p.canCo)).sort((x, y) => fullName(x).localeCompare(fullName(y)));
const assigned = [a.main, a.co].map(personById).filter(Boolean);
f.personId.textContent = '';
f.personId.appendChild(new Option('– bitte wählen –', ''));
if (assigned.length) {
const g = document.createElement('optgroup');
g.label = 'An diesem Tag eingeteilt';
for (const p of assigned) g.appendChild(new Option(fullName(p), p.id));
f.personId.appendChild(g);
}
const g2 = document.createElement('optgroup');
g2.label = 'Alle';
for (const p of active) g2.appendChild(new Option(fullName(p), p.id));
f.personId.appendChild(g2);
const current = f.partnerId.value;
f.partnerId.textContent = '';
f.partnerId.appendChild(new Option('– noch offen –', ''));
for (const p of active) f.partnerId.appendChild(new Option(fullName(p), p.id));
f.partnerId.value = current || '';
$('#request-info').textContent = assigned.length
? `${fmtLong(date)}: ${assigned.map((p, i) => `${i === 0 && a.main === p.id ? 'Haupt' : 'Neben'} ${fullName(p)}`).join(', ')}`
: `${fmtLong(date)}: kein Meeting geplant`;
}
/** Was ist am Wunschtag los? Offene Plätze sichtbar machen, damit der Wunsch passt. */
function describeWishDay() {
const f = requestForm.elements;
const info = $('#request-wish-info');
const base = 'Mit Person: ihr tauscht die Tage. Ohne Person: du übernimmst am Wunschtag einen noch offenen Platz, deiner wird neu besetzt.';
const date = f.partnerDate.value;
if (!date) { info.textContent = base; return; }
const a = state.data.assignments[date];
if (!a || a.off || holidayFor(date)) { info.textContent = `${fmtDate(date)}: kein Meeting geplant.`; return; }
const slot = (role, id) => (id ? `${role} ${fullName(personById(id))}` : `${role} offen`);
info.textContent = `${fmtDate(date)}: ${slot('Haupt', a.main)}, ${slot('Neben', a.co)}.${!a.main || !a.co ? ' Ein offener Platz kann ohne Tauschpartner übernommen werden.' : ' Beide Plätze sind besetzt, nenne die Person zum Tauschen.'}`;
}
requestForm.elements.partnerDate.addEventListener('change', describeWishDay);
function openRequestDialog(date) {
const f = requestForm.elements;
requestForm.reset();
f.date.value = date || '';
fillRequestPersons(f.date.value);
describeWishDay();
$('#request-result').hidden = true;
$('#request-submit').disabled = false;
requestDialog.showModal();
}
$('#open-request').addEventListener('click', () => openRequestDialog(''));
$('#request-close').addEventListener('click', () => requestDialog.close());
requestForm.elements.date.addEventListener('change', () => fillRequestPersons(requestForm.elements.date.value));
requestForm.addEventListener('submit', async (e) => {
e.preventDefault();
const f = requestForm.elements;
const out = $('#request-result');
$('#request-submit').disabled = true;
try {
await api('api/requests', { method: 'POST', body: { personId: f.personId.value, date: f.date.value, partnerId: f.partnerId.value || null, partnerDate: f.partnerDate.value || null, note: f.note.value } });
out.className = 'result';
out.textContent = 'Danke, die Anfrage ist bei der Planung angekommen. Der Tag ist im Kalender markiert, bis sie bearbeitet ist.';
out.hidden = false;
$('#request-close').textContent = 'Schließen';
await load();
} catch (err) {
out.className = 'result error';
out.textContent = err.message;
out.hidden = false;
$('#request-submit').disabled = false;
}
});
requestDialog.addEventListener('close', () => { $('#request-close').textContent = 'Abbrechen'; });
// ---------- Anfragen bearbeiten (nur Bearbeiter) ----------
function requestRow(r) {
const li = document.createElement('li');
const p = personById(r.personId);
li.appendChild(avatarEl(p, 'plain small'));
const grow = document.createElement('div');
grow.className = 'grow';
const strong = document.createElement('strong');
strong.textContent = `${fullName(p)} kann am ${fmtDate(r.date)} nicht (${r.role === 'main' ? 'Haupt' : 'Neben'}moderation)`;
grow.appendChild(strong);
const meta = document.createElement('div');
meta.className = 'request-meta';
const partner = r.partnerId ? personById(r.partnerId) : null;
const parts = [];
if (partner) parts.push(`Wunsch: Tausch mit ${fullName(partner)}${r.partnerDate ? ` am ${fmtDate(r.partnerDate)}` : ' (übernimmt den Tag)'}`);
else if (r.partnerDate) parts.push(`Wunsch: offenen Platz am ${fmtDate(r.partnerDate)} übernehmen`);
else parts.push('Kein Wunsch genannt – aus der Notiz nachtragen oder den Tag öffnen');
parts.push(`gestellt am ${new Date(r.createdAt).toLocaleDateString('de-DE')}`);
if (r.status !== 'offen') parts.push(r.status === 'erledigt' ? 'erledigt' : 'abgelehnt');
meta.textContent = parts.join(' · ');
grow.appendChild(meta);
if (r.note) { const n = document.createElement('div'); n.className = 'request-note'; n.textContent = `„${r.note}"`; grow.appendChild(n); }
li.appendChild(grow);
const actions = document.createElement('div');
actions.className = 'request-actions';
const btn = (label, cls, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = label; b.addEventListener('click', fn); actions.appendChild(b); };
if (r.status === 'offen') {
if (r.partnerId || r.partnerDate) btn('Ausführen', 'primary', async () => {
try {
const res = await api(`api/requests/${r.id}/execute`, { method: 'POST' });
await load();
let msg = 'Tausch ausgeführt.';
if (res.filled) msg += res.filled.personId ? ` Lücke am ${fmtDate(res.filled.date)} mit ${fullName(personById(res.filled.personId))} besetzt.` : ` Lücke am ${fmtDate(res.filled.date)} konnte nicht besetzt werden, bitte im Kalender nachsehen.`;
toast(`${msg} Nextcloud danach neu schreiben.`);
} catch (err) { toast(err.message, true); }
});
btn('Wunsch anpassen', '', () => {
const form = li.querySelector('.wish-form');
form.hidden = !form.hidden;
if (!form.hidden) form.querySelector('input[type=date]').focus();
});
btn('Tag öffnen', '', () => { location.hash = '#kalender'; state.month = new Date(r.date + 'T12:00:00'); renderCalendar(); openDayDialog(r.date); });
btn('Erledigt', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'erledigt' } }), 'Als erledigt markiert.'));
btn('Ablehnen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'abgelehnt' } }), 'Anfrage abgelehnt.'));
} else {
btn('Wieder öffnen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { status: 'offen' } })));
btn('Löschen', 'ghost', () => run(() => api(`api/requests/${r.id}`, { method: 'DELETE' })));
}
li.appendChild(actions);
if (r.status === 'offen') {
// Wunsch nachtragen: Person und/oder Wunschtag, z. B. aus der Notiz
const form = document.createElement('form');
form.className = 'wish-form';
form.hidden = true;
const sel = document.createElement('select');
sel.appendChild(new Option('– niemand Bestimmtes –', ''));
for (const p of state.data.persons.filter((x) => x.active !== false && x.id !== r.personId && (x.canMain || x.canCo)).sort((x, y) => fullName(x).localeCompare(fullName(y)))) sel.appendChild(new Option(fullName(p), p.id));
sel.value = r.partnerId || '';
const dateInput = document.createElement('input');
dateInput.type = 'date';
dateInput.value = r.partnerDate || '';
const save = document.createElement('button');
save.type = 'submit';
save.textContent = 'Wunsch speichern';
const l1 = document.createElement('label'); l1.textContent = 'Tauschen mit'; l1.appendChild(sel);
const l2 = document.createElement('label'); l2.textContent = 'Wunschtag'; l2.appendChild(dateInput);
form.append(l1, l2, save);
form.addEventListener('submit', (e) => {
e.preventDefault();
run(() => api(`api/requests/${r.id}`, { method: 'PUT', body: { partnerId: sel.value || null, partnerDate: dateInput.value || null } }), 'Wunsch gespeichert.');
});
li.appendChild(form);
}
return li;
}
function renderRequests() {
const all = state.data.requests || [];
const open = all.filter((r) => r.status === 'offen').sort((a, b) => a.date.localeCompare(b.date));
const done = all.filter((r) => r.status !== 'offen').sort((a, b) => (b.resolvedAt || '').localeCompare(a.resolvedAt || ''));
const list = $('#request-list');
list.textContent = '';
if (open.length === 0) list.innerHTML = '<li class="hint">Keine offenen Anfragen.</li>';
for (const r of open) list.appendChild(requestRow(r));
const doneList = $('#request-list-done');
doneList.textContent = '';
for (const r of done) doneList.appendChild(requestRow(r));
doneList.hidden = !$('#requests-show-done').checked || done.length === 0;
$('#requests-toggle').hidden = done.length === 0;
}
$('#requests-show-done').addEventListener('change', renderRequests);
// ---------- 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 = '<li class="hint">Noch niemand angelegt.</li>'; return; }
for (const p of persons) {
const li = document.createElement('li');
if (p.active === false) li.classList.add('inactive');
const av = avatarEl(p, 'plain');
av.title = p.hasPhoto ? 'Foto ändern' : 'Foto hochladen';
av.tabIndex = 0;
av.setAttribute('role', 'button');
av.setAttribute('aria-label', `Foto für ${fullName(p)} hochladen`);
av.addEventListener('click', () => choosePhoto(p.id));
av.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); choosePhoto(p.id); } });
li.appendChild(av);
const who = document.createElement('div');
who.className = 'who';
const strong = document.createElement('strong');
strong.textContent = fullName(p);
who.appendChild(strong);
if (p.email || p.note) { const s = document.createElement('small'); s.textContent = [p.email, p.note].filter(Boolean).join(' · '); who.appendChild(s); }
const tags = document.createElement('div');
tags.className = 'tags';
if (p.canMain) tags.appendChild(tag('Haupt', 'main'));
if (p.canCo) tags.appendChild(tag('Neben', 'co'));
if (!p.canMain && !p.canCo) tags.appendChild(tag('Nicht-Moderator', 'off'));
if (p.freeDays && p.freeDays.length) tags.appendChild(tag(`frei: ${p.freeDays.map((d) => WEEKDAYS[d - 1]).join(', ')}`));
if (p.active === false) tags.appendChild(tag('Inaktiv'));
who.appendChild(tags);
li.appendChild(who);
const edit = document.createElement('button');
edit.type = 'button';
edit.textContent = 'Bearbeiten';
edit.addEventListener('click', () => editPerson(p));
li.appendChild(edit);
const photoBtn = document.createElement('button');
photoBtn.type = 'button';
photoBtn.className = 'ghost';
photoBtn.textContent = p.hasPhoto ? 'Foto ändern' : 'Foto hochladen';
photoBtn.addEventListener('click', () => choosePhoto(p.id));
li.appendChild(photoBtn);
if (p.hasPhoto) {
const rm = document.createElement('button');
rm.type = 'button';
rm.className = 'ghost';
rm.textContent = 'Foto entfernen';
rm.addEventListener('click', () => run(() => api(`api/persons/${p.id}/photo`, { method: 'DELETE' }), 'Foto entfernt.'));
li.appendChild(rm);
}
const del = document.createElement('button');
del.type = 'button';
del.className = 'ghost danger';
del.textContent = 'Löschen';
del.addEventListener('click', async () => {
if (!confirm(`${fullName(p)} wirklich löschen? Zuteilungen dieser Person werden geleert.`)) return;
try { await api(`api/persons/${p.id}`, { method: 'DELETE' }); await load(); toast('Person gelöscht.'); } catch (err) { toast(err.message, true); }
});
li.appendChild(del);
list.appendChild(li);
}
}
function tag(text, cls = '') { const s = document.createElement('span'); s.className = `tag ${cls}`; s.textContent = text; return s; }
// Foto im Formular: pending = neues Bild (Blob), remove = vorhandenes Foto beim Speichern löschen
const photoState = { pending: null, remove: false, person: null, url: null };
function setPendingPhoto(blob) {
if (photoState.url) URL.revokeObjectURL(photoState.url);
photoState.pending = blob;
photoState.remove = false;
photoState.url = blob ? URL.createObjectURL(blob) : null;
renderPhotoField();
}
function renderPhotoField() {
const preview = $('#photo-preview');
const status = $('#photo-status');
const clear = $('#photo-clear');
preview.textContent = '';
preview.style.backgroundImage = '';
preview.classList.remove('empty');
const p = photoState.person;
const hasExisting = Boolean(p && p.hasPhoto && !photoState.remove);
if (photoState.pending) {
preview.style.backgroundImage = `url("${photoState.url}")`;
preview.style.backgroundSize = 'cover';
status.textContent = 'Neues Foto gewählt. Wird mit „Speichern" bzw. „Person anlegen" übernommen.';
clear.hidden = false;
clear.textContent = 'Auswahl verwerfen';
} else if (hasExisting) {
preview.style.backgroundImage = `url("photos/${p.id}?v=${p.photoVersion || 0}")`;
preview.style.backgroundSize = 'cover';
status.textContent = 'Vorhandenes Foto. Datei wählen, hierher ziehen oder mit Strg+V ersetzen.';
clear.hidden = false;
clear.textContent = 'Foto entfernen';
} else {
preview.classList.add('empty');
preview.textContent = p ? initials(p) : '';
status.textContent = photoState.remove ? 'Foto wird beim Speichern entfernt.' : 'Kein Foto. Datei wählen, hierher ziehen oder mit Strg+V aus der Zwischenablage einfügen.';
clear.hidden = !photoState.remove;
clear.textContent = 'Entfernen rückgängig';
}
}
async function acceptPhotoFile(file) {
if (!file || !file.type.startsWith('image/')) { toast('Das ist kein Bild.', true); return; }
try {
setPendingPhoto(await squareJpeg(file, 320));
$('#photo-field').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (err) { toast(`Bild konnte nicht verarbeitet werden: ${err.message}`, true); }
}
function imageFromClipboard(data) {
if (!data) return null;
for (const item of data.items || []) {
if (item.kind === 'file' && item.type.startsWith('image/')) return item.getAsFile();
}
for (const file of data.files || []) if (file.type.startsWith('image/')) return file;
return null;
}
// Strg+V überall in der Personenansicht: Bild aus der Zwischenablage landet im Formular
document.addEventListener('paste', (e) => {
if ($('#view-personen').hidden || !state.data || !isEditor()) return;
const file = imageFromClipboard(e.clipboardData);
if (!file) return;
e.preventDefault();
acceptPhotoFile(file);
toast('Bild aus der Zwischenablage übernommen.');
});
$('#photo-paste').addEventListener('click', async () => {
// Schaltfläche für Mäuse und Touch: liest die Zwischenablage, wenn der Browser es erlaubt
if (!navigator.clipboard?.read) { toast('Dein Browser erlaubt das nur per Tastatur: Strg+V im Formular drücken.', true); return; }
try {
for (const item of await navigator.clipboard.read()) {
const type = item.types.find((t) => t.startsWith('image/'));
if (type) { await acceptPhotoFile(new File([await item.getType(type)], 'clipboard', { type })); return; }
}
toast('Kein Bild in der Zwischenablage.', true);
} catch { toast('Zugriff auf die Zwischenablage abgelehnt. Alternativ Strg+V drücken.', true); }
});
$('#photo-choose').addEventListener('click', () => { photoTarget = null; $('#photo-input').value = ''; $('#photo-input').click(); });
$('#photo-clear').addEventListener('click', () => {
if (photoState.pending) { setPendingPhoto(null); return; }
photoState.remove = !photoState.remove;
renderPhotoField();
});
const photoField = $('#photo-field');
photoField.addEventListener('dragover', (e) => { e.preventDefault(); photoField.classList.add('dragover'); });
photoField.addEventListener('dragleave', () => photoField.classList.remove('dragover'));
photoField.addEventListener('drop', (e) => {
e.preventDefault();
photoField.classList.remove('dragover');
const file = [...(e.dataTransfer?.files || [])].find((f) => f.type.startsWith('image/'));
if (file) acceptPhotoFile(file); else toast('Bitte eine Bilddatei ablegen.', true);
});
function editPerson(p) {
const f = personForm.elements;
f.id.value = p.id;
f.firstName.value = p.firstName;
f.lastName.value = p.lastName;
f.email.value = p.email || '';
f.note.value = p.note || '';
f.canMain.checked = Boolean(p.canMain);
f.canCo.checked = Boolean(p.canCo);
f.active.checked = p.active !== false;
for (const cb of personForm.querySelectorAll('input[name="freeDays"]')) cb.checked = (p.freeDays || []).includes(Number(cb.value));
photoState.person = p;
photoState.remove = false;
setPendingPhoto(null);
$('#person-submit').textContent = 'Änderungen speichern';
$('#person-cancel').hidden = false;
personForm.scrollIntoView({ behavior: 'smooth', block: 'start' });
f.firstName.focus();
}
function resetPersonForm() {
personForm.reset();
personForm.elements.id.value = '';
photoState.person = null;
photoState.remove = false;
setPendingPhoto(null);
$('#person-submit').textContent = 'Person anlegen';
$('#person-cancel').hidden = true;
}
$('#person-cancel').addEventListener('click', resetPersonForm);
personForm.addEventListener('submit', async (e) => {
e.preventDefault();
const f = personForm.elements;
const freeDays = [...personForm.querySelectorAll('input[name="freeDays"]:checked')].map((cb) => Number(cb.value));
const body = { firstName: f.firstName.value, lastName: f.lastName.value, email: f.email.value, note: f.note.value, canMain: f.canMain.checked, canCo: f.canCo.checked, active: f.active.checked, freeDays };
try {
const saved = f.id.value
? await api(`api/persons/${f.id.value}`, { method: 'PUT', body })
: await api('api/persons', { method: 'POST', body });
if (photoState.pending) await api(`api/persons/${saved.id}/photo`, { method: 'PUT', raw: { body: photoState.pending, type: 'image/jpeg' } });
else if (photoState.remove) await api(`api/persons/${saved.id}/photo`, { method: 'DELETE' });
resetPersonForm();
await load();
toast('Gespeichert.');
} catch (err) { toast(err.message, true); }
});
const contactsDialog = $('#contacts-dialog');
const contactsForm = $('#contacts-form');
let contactsData = null;
async function loadContacts(addressbook = '') {
const info = $('#contacts-info');
const list = $('#contacts-list');
list.textContent = '';
info.textContent = 'Lade Kontakte …';
$('#contacts-go').disabled = true;
try {
contactsData = await api(`api/nextcloud/contacts${addressbook ? `?addressbook=${encodeURIComponent(addressbook)}` : ''}`);
const sel = contactsForm.elements.addressbook;
sel.textContent = '';
for (const b of contactsData.addressbooks) sel.appendChild(new Option(b.system ? `${b.name} (alle Nextcloud-Konten)` : b.name, b.href));
sel.value = contactsData.addressbook;
const fresh = contactsData.contacts.filter((c) => !c.existingId);
info.textContent = contactsData.contacts.length
? `${contactsData.contacts.length} Kontakte, ${fresh.length} davon noch nicht angelegt. Fotos werden übernommen, wenn vorhanden.`
: 'Keine Kontakte gefunden.';
for (const c of contactsData.contacts) {
const li = document.createElement('li');
if (c.existingId) li.classList.add('exists');
const label = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = c.uid;
cb.disabled = Boolean(c.existingId);
cb.checked = !c.existingId;
label.appendChild(cb);
const text = document.createElement('span');
text.textContent = `${c.firstName} ${c.lastName}`.trim();
label.appendChild(text);
const small = document.createElement('small');
small.textContent = [c.email, c.existingId ? 'bereits angelegt' : '', c.hasPhoto ? '' : 'kein Foto'].filter(Boolean).join(' · ');
label.appendChild(small);
li.appendChild(label);
list.appendChild(li);
}
$('#contacts-go').disabled = fresh.length === 0;
} catch (err) {
info.textContent = `Fehler: ${err.message}`;
}
}
$('#open-contacts').addEventListener('click', () => {
$('#contacts-result').hidden = true;
contactsDialog.showModal();
if (!state.data.nextcloud.configured) { $('#contacts-info').textContent = 'Nextcloud ist nicht konfiguriert.'; $('#contacts-go').disabled = true; return; }
loadContacts();
});
contactsForm.elements.addressbook.addEventListener('change', (e) => loadContacts(e.target.value));
$('#contacts-close').addEventListener('click', () => contactsDialog.close());
$('#contacts-all').addEventListener('click', () => { for (const cb of contactsForm.querySelectorAll('#contacts-list input:not(:disabled)')) cb.checked = true; });
$('#contacts-none').addEventListener('click', () => { for (const cb of contactsForm.querySelectorAll('#contacts-list input:not(:disabled)')) cb.checked = false; });
contactsForm.addEventListener('submit', async (e) => {
e.preventDefault();
const uids = [...contactsForm.querySelectorAll('#contacts-list input:checked:not(:disabled)')].map((cb) => cb.value);
const out = $('#contacts-result');
if (uids.length === 0) { out.className = 'result error'; out.textContent = 'Nichts ausgewählt.'; out.hidden = false; return; }
const btn = $('#contacts-go');
btn.disabled = true;
out.className = 'result';
out.textContent = `Lege ${uids.length} Person(en) an und hole Fotos …`;
out.hidden = false;
try {
const res = await api('api/nextcloud/contacts/import', { method: 'POST', body: { uids, addressbook: contactsForm.elements.addressbook.value } });
await load();
out.textContent = `${res.created} Person(en) angelegt, ${res.withPhoto} mit Foto.${res.placeholders ? ` ${res.placeholders} Initialen-Platzhalter aus Nextcloud nicht übernommen.` : ''}${res.skipped ? ` ${res.skipped} übersprungen.` : ''}`;
await loadContacts(contactsForm.elements.addressbook.value);
} catch (err) { out.className = 'result error'; out.textContent = err.message; }
btn.disabled = false;
});
$('#remove-placeholders').addEventListener('click', async () => {
const btn = $('#remove-placeholders');
btn.disabled = true;
try {
const { ids } = await api('api/persons/placeholder-photos');
if (ids.length === 0) { toast('Keine Platzhalter-Fotos gefunden.'); btn.disabled = false; return; }
if (!confirm(`${ids.length} Foto(s) sehen wie Initialen-Platzhalter aus (eine Hintergrundfarbe mit Buchstaben). Entfernen? Die Personen bekommen dann Initialen im Rotatory-Stil.`)) { btn.disabled = false; return; }
const res = await api('api/persons/placeholder-photos/remove', { method: 'POST' });
await load();
toast(`${res.removed} Platzhalter-Foto(s) entfernt.`);
} catch (err) { toast(err.message, true); }
btn.disabled = false;
});
let photoTarget = null;
function choosePhoto(id) { photoTarget = id; $('#photo-input').value = ''; $('#photo-input').click(); }
$('#photo-input').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!photoTarget) { acceptPhotoFile(file); return; }
try {
const blob = await squareJpeg(file, 320);
await api(`api/persons/${photoTarget}/photo`, { method: 'PUT', raw: { body: blob, type: 'image/jpeg' } });
await load();
toast('Foto gespeichert.');
} catch (err) { toast(`Foto konnte nicht verarbeitet werden: ${err.message}`, true); }
});
/** Bild im Browser quadratisch zuschneiden und verkleinern – spart Speicher und Bandbreite. */
async function squareJpeg(file, size) {
const bitmap = await createImageBitmap(file);
const side = Math.min(bitmap.width, bitmap.height);
const sx = (bitmap.width - side) / 2;
const sy = (bitmap.height - side) / 2;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
canvas.getContext('2d').drawImage(bitmap, sx, sy, side, side, 0, 0, size, size);
return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Canvas leer'))), 'image/jpeg', 0.86));
}
// ---------- Abwesenheiten ----------
const absenceForm = $('#absence-form');
const TYPE_LABEL = { urlaub: 'Urlaub', krankheit: 'Krankheit', frei: 'Frei', sonstiges: 'Sonstiges' };
const absenceImport = $('#absence-import');
let absenceCalendarsLoaded = false;
async function prepareAbsenceImport() {
if (!absenceImport.elements.from.value) {
const now = startOfMonth(new Date());
absenceImport.elements.from.value = monthKey(now);
absenceImport.elements.to.value = monthKey(new Date(now.getFullYear(), now.getMonth() + 3, 1));
}
if (absenceCalendarsLoaded) return;
absenceCalendarsLoaded = true;
const list = await fillCalendarSelect(absenceImport.elements.calendarHref, { selected: state.data.settings.absenceCalendarHref });
if (!state.data.settings.absenceCalendarHref) {
const guess = list.find((c) => /urlaub|abwesen|krank|ferien|vacation|absence/i.test(c.name));
if (guess) absenceImport.elements.calendarHref.value = guess.href;
}
}
absenceImport.addEventListener('submit', async (e) => {
e.preventDefault();
const f = absenceImport.elements;
const out = $('#absence-import-result');
const r = monthRange(f.from.value, f.to.value);
const btn = $('#absence-import-go');
btn.disabled = true;
out.className = 'result';
out.textContent = 'Lese Kalender …';
out.hidden = false;
try {
const res = await api('api/nextcloud/absences/import', { method: 'POST', body: { ...r, calendarHref: f.calendarHref.value, calendarName: selectedCalendarName(f.calendarHref) } });
await load();
const parts = [`${res.imported} Abwesenheit(en) übernommen (${res.events} Termine gelesen, ${res.replaced} frühere Importe ersetzt).`];
if (res.unmatched.length) parts.push(`Nicht zugeordnet – Person fehlt oder Name weicht ab:\n${res.unmatched.map((u) => `• ${u.name} (${u.count}×)`).join('\n')}`);
out.textContent = parts.join('\n');
} catch (err) { out.className = 'result error'; out.textContent = err.message; }
btn.disabled = false;
});
function renderAbsences() {
const sel = absenceForm.elements.personId;
const current = sel.value;
sel.textContent = '';
for (const p of [...state.data.persons].filter((p) => p.active !== false).sort((a, b) => fullName(a).localeCompare(fullName(b)))) {
const o = document.createElement('option');
o.value = p.id;
o.textContent = fullName(p);
sel.appendChild(o);
}
if (current) sel.value = current;
const list = $('#absence-list');
list.textContent = '';
const showPast = $('#absence-show-past').checked;
const today = todayIso();
const items = state.data.absences.filter((a) => showPast || a.to >= today).sort((a, b) => a.from.localeCompare(b.from));
if (items.length === 0) { list.innerHTML = '<li class="hint">Keine Abwesenheiten eingetragen.</li>'; return; }
for (const a of items) {
const p = personById(a.personId);
const li = document.createElement('li');
li.appendChild(avatarEl(p, 'plain small'));
const grow = document.createElement('div');
grow.className = 'grow';
const strong = document.createElement('strong');
strong.textContent = fullName(p);
grow.appendChild(strong);
const small = document.createElement('small');
small.innerHTML = ` <span class="type-${a.type}">${TYPE_LABEL[a.type] || a.type}</span> · ${fmtDate(a.from)} – ${fmtDate(a.to)}${a.note ? ' · ' : ''}`;
small.appendChild(document.createTextNode(a.note || ''));
grow.appendChild(small);
li.appendChild(grow);
if (a.source === 'nextcloud') li.appendChild(tag('Nextcloud'));
const conflicts = Object.entries(state.data.assignments).filter(([d, x]) => d >= a.from && d <= a.to && !x.off && (x.main === a.personId || x.co === a.personId));
if (conflicts.length) {
const warn = document.createElement('span');
warn.className = 'tag conflict';
warn.textContent = `${conflicts.length}× eingeplant`;
warn.title = 'Diese Person ist an Tagen im Kalender eingeplant, an denen sie abwesend ist. Im Kalender neu auslosen.';
li.appendChild(warn);
}
const del = document.createElement('button');
del.type = 'button';
del.className = 'ghost';
del.textContent = 'Entfernen';
del.addEventListener('click', () => run(() => api(`api/absences/${a.id}`, { method: 'DELETE' }), 'Abwesenheit entfernt.'));
li.appendChild(del);
list.appendChild(li);
}
}
$('#absence-show-past').addEventListener('change', renderAbsences);
absenceForm.addEventListener('submit', async (e) => {
e.preventDefault();
const f = absenceForm.elements;
try {
await api('api/absences', { method: 'POST', body: { personId: f.personId.value, type: f.type.value, from: f.from.value, to: f.to.value, note: f.note.value } });
f.note.value = '';
await load();
toast('Abwesenheit eingetragen.');
} catch (err) { toast(err.message, true); }
});
absenceForm.elements.from.addEventListener('change', () => { if (!absenceForm.elements.to.value || absenceForm.elements.to.value < absenceForm.elements.from.value) absenceForm.elements.to.value = absenceForm.elements.from.value; });
// ---------- Einstellungen ----------
const settingsForm = $('#settings-form');
function renderSettings() {
const s = state.data.settings;
const f = settingsForm.elements;
const sel = f.bundesland;
if (sel.options.length === 0) {
for (const [code, name] of Object.entries(state.data.bundeslaender)) {
const o = document.createElement('option');
o.value = code;
o.textContent = name;
sel.appendChild(o);
}
}
f.meetingTime.value = s.meetingTime || '';
f.durationMinutes.value = s.durationMinutes;
f.bundesland.value = s.bundesland || '';
f.historyWindowDays.value = s.historyWindowDays;
f.calendarDisplayName.value = s.calendarDisplayName;
f.inviteAttendees.checked = Boolean(s.inviteAttendees);
f.bugPromptTemplate.value = s.bugPromptTemplate || '';
if (!$('#holiday-generate').elements.year.value) $('#holiday-generate').elements.year.value = new Date().getFullYear();
const list = $('#holiday-list');
list.textContent = '';
const today = todayIso();
const items = state.data.holidays.filter((h) => h.date >= today.slice(0, 4) + '-01-01');
if (items.length === 0) list.innerHTML = '<li class="hint">Keine Feiertage hinterlegt – oben für das Jahr erzeugen.</li>';
for (const h of items) {
const li = document.createElement('li');
const grow = document.createElement('span');
grow.className = 'grow';
grow.textContent = `${fmtDate(h.date)} – ${h.name}`;
li.appendChild(grow);
if (!h.auto) li.appendChild(tag('manuell'));
const del = document.createElement('button');
del.type = 'button';
del.className = 'ghost';
del.textContent = 'Entfernen';
del.addEventListener('click', () => run(() => api(`api/holidays/${h.date}`, { method: 'DELETE' }), 'Eintrag entfernt.'));
li.appendChild(del);
list.appendChild(li);
}
const protectedHint = state.data.auth?.protected
? '<p class="status-ok">Bearbeitung ist durch das Passwort aus <code>EDIT_PASSWORD</code> geschützt. Das Team sieht nur den Kalender.</p>'
: '<p class="status-warn">Kein Bearbeitungspasswort gesetzt: Jeder, der die Seite öffnet, kann auch bearbeiten. Zum Schützen <code>EDIT_PASSWORD</code> als Umgebungsvariable setzen.</p>';
const nc = state.data.nextcloud;
const box = $('#nextcloud-status');
box.innerHTML = protectedHint + (nc.configured
? `<p class="status-ok">Konfiguriert: <code>${escapeHtml(nc.baseUrl)}</code>, angemeldet als <code>${escapeHtml(nc.user)}</code>.</p>`
+ `<p>Zielkalender: <strong>${s.targetCalendarHref ? escapeHtml(s.targetCalendarName || s.targetCalendarHref) : `neuer Kalender „${escapeHtml(s.calendarDisplayName)}" (${escapeHtml(nc.calendar)})`}</strong> – wählbar im Dialog „Nextcloud …" im Kalender.</p>`
+ `<p>Quellkalender für Abwesenheiten: <strong>${s.absenceCalendarHref ? escapeHtml(s.absenceCalendarName || s.absenceCalendarHref) : 'noch keiner'}</strong> – wählbar unter „Abwesenheiten".</p>`
: `<p class="status-warn">Nicht konfiguriert.</p><p class="hint">Umgebungsvariablen setzen: <code>NEXTCLOUD_URL</code>, <code>NEXTCLOUD_USER</code>, <code>NEXTCLOUD_APP_PASSWORD</code> (App-Passwort aus Nextcloud → Einstellungen → Sicherheit), optional <code>NEXTCLOUD_CALENDAR</code>. Bis dahin steht der ICS-Download zur Verfügung.</p>`);
}
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
settingsForm.addEventListener('submit', async (e) => {
e.preventDefault();
const f = settingsForm.elements;
try {
await api('api/settings', { method: 'PUT', body: {
meetingTime: f.meetingTime.value, durationMinutes: Number(f.durationMinutes.value), bundesland: f.bundesland.value,
historyWindowDays: Number(f.historyWindowDays.value), calendarDisplayName: f.calendarDisplayName.value, inviteAttendees: f.inviteAttendees.checked,
bugPromptTemplate: f.bugPromptTemplate.value,
} });
await load();
toast('Einstellungen gespeichert.');
} catch (err) { toast(err.message, true); }
});
$('#holiday-generate').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api('api/holidays/generate', { method: 'POST', body: { year: Number(e.target.elements.year.value) } });
await load();
toast('Feiertage erzeugt.');
} catch (err) { toast(err.message, true); }
});
$('#holiday-add').addEventListener('submit', async (e) => {
e.preventDefault();
const f = e.target.elements;
try {
await api('api/holidays', { method: 'POST', body: { date: f.date.value, name: f.name.value } });
f.name.value = '';
await load();
} catch (err) { toast(err.message, true); }
});
// ---------- Bug melden (für alle) ----------
const bugPanel = $('#bug-panel');
const bugCanvas = $('#bug-canvas');
const bugState = { base: null, rects: [], draft: null, sending: false, capture: null, closeTimer: null, target: null };
function viewName() {
const v = location.hash.slice(1) || 'kalender';
const names = { kalender: 'Kalender', personen: 'Personen', abwesenheiten: 'Abwesenheiten', anfragen: 'Anfragen', bugs: 'Bugs', einstellungen: 'Einstellungen' };
const month = v === 'kalender' ? ` (${MONTHS[state.month.getMonth()]} ${state.month.getFullYear()})` : '';
return `${names[v] || v}${month}${isEditor() ? ', angemeldet' : ''}`;
}
/**
* Sichtbaren Bildschirm als Canvas aufnehmen – das Meldefenster selbst bleibt außen vor.
* html2canvas zeichnet die Seite aus dem DOM nach. Zwei Dinge kann es nicht: position: sticky (die
* Kopfzeile) und box-shadow-Ringe (sie übermalen das Kennzeichen). Beides wird in der Kopie des
* Dokuments korrigiert, die html2canvas rendert – die echte Seite bleibt unberührt.
*/
async function captureScreenshot(target = null) {
if (typeof window.html2canvas !== 'function') throw new Error('Die Screenshot-Funktion ist nicht geladen.');
// Auflösung begrenzen: höchstens 1,5-fach und höchstens 2560 px breit, damit das Bild unter 6 MB bleibt
const scale = Math.min(window.devicePixelRatio || 1, 1.5, Math.max(1, 2560 / window.innerWidth));
// Schatten malt html2canvas als Fläche über das Element – in der Kopie weglassen
const extraStyle = '.slot .badge, .req-mark, dialog, .chip, .card { box-shadow: none; } .logo .arc { animation: none; }';
if (target) {
// Ein offener Dialog: nur ihn aufnehmen, er ist das, was der Melder gerade sieht
return window.html2canvas(target, {
backgroundColor: '#ffffff',
scale,
logging: false,
useCORS: true,
ignoreElements: (el) => el.id === 'bug-widget' || el.id === 'toast' || el.classList?.contains('dialog-bug'),
onclone: (doc) => { const style = doc.createElement('style'); style.textContent = extraStyle; doc.head.appendChild(style); },
});
}
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const topbarHeight = $('.topbar').offsetHeight;
return window.html2canvas(document.documentElement, {
backgroundColor: '#ffffff',
scale,
logging: false,
useCORS: true,
ignoreElements: (el) => el.id === 'bug-widget' || el.id === 'toast',
x: scrollX,
y: scrollY,
width: window.innerWidth,
height: window.innerHeight,
scrollX: 0,
scrollY: 0,
windowWidth: document.documentElement.clientWidth,
windowHeight: document.documentElement.clientHeight,
onclone: (doc) => {
const bar = doc.querySelector('.topbar');
if (bar) {
// Kopfzeile dorthin setzen, wo sie gerade klebt; der freigewordene Platz oben wird aufgefüllt
bar.style.position = 'absolute';
bar.style.top = `${scrollY}px`;
bar.style.left = '0';
bar.style.right = '0';
doc.body.style.paddingTop = `${topbarHeight}px`;
}
const style = doc.createElement('style');
style.textContent = extraStyle;
doc.head.appendChild(style);
},
});
}
function drawBugCanvas() {
const c = bugCanvas;
if (!bugState.base) return;
const ctx = c.getContext('2d');
ctx.clearRect(0, 0, c.width, c.height);
ctx.drawImage(bugState.base, 0, 0);
const lw = Math.max(3, Math.round(c.width / 400));
ctx.lineWidth = lw;
ctx.strokeStyle = '#B42318';
ctx.lineJoin = 'miter';
for (const r of [...bugState.rects, ...(bugState.draft ? [bugState.draft] : [])]) {
ctx.strokeRect(r.x + lw / 2, r.y + lw / 2, Math.max(0, r.w - lw), Math.max(0, r.h - lw));
}
$('#bug-clear-marks').hidden = bugState.rects.length === 0;
}
function canvasPoint(e) {
const rect = bugCanvas.getBoundingClientRect();
const x = Math.min(Math.max(0, (e.clientX - rect.left) / rect.width * bugCanvas.width), bugCanvas.width);
const y = Math.min(Math.max(0, (e.clientY - rect.top) / rect.height * bugCanvas.height), bugCanvas.height);
return { x, y };
}
let bugDrawStart = null;
bugCanvas.addEventListener('pointerdown', (e) => {
if (!bugState.base) return;
e.preventDefault();
bugCanvas.setPointerCapture(e.pointerId);
bugDrawStart = canvasPoint(e);
bugState.draft = { x: bugDrawStart.x, y: bugDrawStart.y, w: 0, h: 0 };
});
bugCanvas.addEventListener('pointermove', (e) => {
if (!bugDrawStart) return;
const p = canvasPoint(e);
bugState.draft = { x: Math.min(p.x, bugDrawStart.x), y: Math.min(p.y, bugDrawStart.y), w: Math.abs(p.x - bugDrawStart.x), h: Math.abs(p.y - bugDrawStart.y) };
drawBugCanvas();
});
const endBugDraw = () => {
if (!bugDrawStart) return;
const d = bugState.draft;
const min = bugCanvas.width / 150;
if (d && d.w > min && d.h > min) bugState.rects.push(d);
bugState.draft = null;
bugDrawStart = null;
drawBugCanvas();
};
bugCanvas.addEventListener('pointerup', endBugDraw);
bugCanvas.addEventListener('pointercancel', endBugDraw);
$('#bug-clear-marks').addEventListener('click', () => { bugState.rects = []; drawBugCanvas(); });
function closeBugPanel() {
if (bugPanel.open) bugPanel.close(); // löst 'close' aus, dort wird aufgeräumt
}
// Aufräumen bei jedem Schließen: Escape, Abbrechen, automatisch nach dem Senden
bugPanel.addEventListener('close', () => {
clearTimeout(bugState.closeTimer);
bugState.closeTimer = null;
bugState.capture = null;
bugState.base = null;
bugState.rects = [];
bugState.draft = null;
bugState.target = null;
$('#bug-button').setAttribute('aria-expanded', 'false');
});
/** Panel öffnen und aufnehmen. target = offener Dialog, der stattdessen fotografiert wird. */
async function openBugPanel(target = null) {
if (bugPanel.open) return;
clearTimeout(bugState.closeTimer);
bugState.closeTimer = null;
bugState.target = target;
$('#bug-button').setAttribute('aria-expanded', 'true');
$('#bug-shot').hidden = true;
$('#bug-shot-hint').hidden = true;
$('#bug-result').hidden = true;
$('#bug-submit').disabled = false;
$('#bug-text').value = '';
try { $('#bug-reporter').value = localStorage.getItem('rotatory.reporter') || ''; } catch { /* kein Speicher */ }
const status = $('#bug-status');
status.hidden = false;
status.textContent = 'Bildschirm wird aufgenommen …';
bugState.base = null;
bugState.rects = [];
bugPanel.showModal(); // auch über anderen offenen Dialogen bedienbar
$('#bug-text').focus();
const capture = captureScreenshot(target);
bugState.capture = capture;
try {
const shot = await capture;
if (bugState.capture !== capture) return; // inzwischen geschlossen oder neu geöffnet
bugState.base = shot;
bugCanvas.width = shot.width;
bugCanvas.height = shot.height;
drawBugCanvas();
status.hidden = true;
$('#bug-shot').hidden = false;
$('#bug-shot-hint').hidden = false;
} catch (err) {
if (bugState.capture !== capture) return;
status.textContent = `Screenshot nicht möglich (${err.message}). Beschreibe den Fehler bitte trotzdem.`;
} finally {
if (bugState.capture === capture) bugState.capture = null;
}
}
$('#bug-button').addEventListener('click', () => { if (bugPanel.open) closeBugPanel(); else openBugPanel(); });
$('#bug-cancel').addEventListener('click', closeBugPanel);
// Käfer im Kopf jedes Dialogs: nimmt den Dialog auf und öffnet das Panel darüber
document.addEventListener('click', (e) => {
const trigger = e.target.closest('.dialog-bug');
if (trigger) openBugPanel(trigger.closest('dialog'));
});
$('#bug-form').addEventListener('submit', async (e) => {
e.preventDefault();
if (bugState.sending) return;
const out = $('#bug-result');
const text = $('#bug-text').value.trim();
if (!text) { out.className = 'result error'; out.textContent = 'Bitte beschreibe kurz, was falsch läuft.'; out.hidden = false; return; }
bugState.sending = true;
$('#bug-submit').disabled = true;
out.className = 'result';
out.textContent = 'Wird gesendet …';
out.hidden = false;
const reporter = $('#bug-reporter').value.trim();
try { localStorage.setItem('rotatory.reporter', reporter); } catch { /* kein Speicher */ }
// Läuft die Aufnahme noch, erst abwarten – sonst ginge die Meldung ohne Bild raus
if (bugState.capture) {
out.textContent = 'Bildschirm wird noch aufgenommen …';
try { await bugState.capture; } catch { /* dann eben ohne Screenshot */ }
out.textContent = 'Wird gesendet …';
}
const page = bugState.target ? `${viewName()}, Dialog „${bugState.target.querySelector('h2')?.textContent.trim() || ''}"` : viewName();
try {
let screenshot = bugState.base ? bugCanvas.toDataURL('image/png') : '';
if (screenshot.length > 8_000_000) screenshot = bugCanvas.toDataURL('image/jpeg', 0.85); // großes Display: kleiner als JPEG
await api('api/bugs', { method: 'POST', body: { text, reporter, page, url: location.href, viewport: `${window.innerWidth}×${window.innerHeight}`, userAgent: navigator.userAgent, screenshot } });
} catch (err) {
out.className = 'result error';
out.textContent = err.message;
$('#bug-submit').disabled = false;
bugState.sending = false;
return;
}
bugState.sending = false;
// Panel sofort schließen und die Bestätigung unten einblenden
closeBugPanel();
toast('Bug erfolgreich gemeldet. Danke für den Hinweis.');
if (isEditor()) load().catch((err) => toast(err.message, true));
});
// ---------- Bugs bearbeiten (nur Bearbeiter) ----------
const canCopyImages = () => Boolean(window.isSecureContext && navigator.clipboard && navigator.clipboard.write && window.ClipboardItem);
async function copyText(text) {
if (window.isSecureContext && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; }
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
if (!ok) throw new Error('Der Browser hat das Kopieren abgelehnt.');
}
async function copyImage(url) {
const blob = await (await fetch(url)).blob();
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
}
function browserLabel(ua) {
const u = String(ua || '');
const m = u.match(/(Edg|OPR|Firefox|Chrome|Safari|Version)\/(\d+)/g) || [];
const pick = (name) => m.find((x) => x.startsWith(name + '/'));
const b = pick('Edg') ? `Edge ${pick('Edg').split('/')[1]}` : pick('OPR') ? `Opera ${pick('OPR').split('/')[1]}` : pick('Firefox') ? `Firefox ${pick('Firefox').split('/')[1]}` : pick('Chrome') ? `Chrome ${pick('Chrome').split('/')[1]}` : pick('Version') && /Safari/.test(u) ? `Safari ${pick('Version').split('/')[1]}` : 'Browser';
const os = /Windows/.test(u) ? 'Windows' : /Mac OS X/.test(u) && !/iPhone|iPad/.test(u) ? 'macOS' : /iPhone|iPad/.test(u) ? 'iOS' : /Android/.test(u) ? 'Android' : /Linux/.test(u) ? 'Linux' : '';
return os ? `${b}, ${os}` : b;
}
function bugRow(b) {
const li = document.createElement('li');
if (b.hasScreenshot) {
const a = document.createElement('a');
a.className = 'bug-thumb';
a.href = `bugs/${b.id}.png`;
a.target = '_blank';
a.rel = 'noopener';
a.title = 'Screenshot in voller Größe öffnen';
const img = document.createElement('img');
img.src = `bugs/${b.id}.png`;
img.alt = 'Screenshot zum Bug';
img.loading = 'lazy';
a.appendChild(img);
li.appendChild(a);
}
const grow = document.createElement('div');
grow.className = 'grow';
const text = document.createElement('div');
text.className = 'bug-text';
text.textContent = b.text;
grow.appendChild(text);
const meta = document.createElement('div');
meta.className = 'bug-meta';
meta.textContent = [b.reporter || 'anonym', new Date(b.createdAt).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }), b.page, b.viewport, browserLabel(b.userAgent), b.version ? `Version ${b.version}` : '', b.status === 'erledigt' ? 'erledigt' : ''].filter(Boolean).join(' · ');
grow.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'bug-actions';
const btn = (label, cls, fn) => { const el = document.createElement('button'); el.type = 'button'; el.className = cls; el.textContent = label; el.addEventListener('click', fn); actions.appendChild(el); return el; };
btn('Prompt kopieren', 'primary', async () => {
try {
const res = await fetch(`api/bugs/${b.id}/prompt`);
if (!res.ok) throw new Error('Prompt konnte nicht geladen werden.');
await copyText(await res.text());
toast('Prompt kopiert. Jetzt in Claude Code einfügen, Screenshot dazu.');
} catch (err) { toast(err.message, true); }
});
if (b.hasScreenshot) {
if (canCopyImages()) {
btn('Screenshot kopieren', '', async () => {
try { await copyImage(`bugs/${b.id}.png`); toast('Screenshot kopiert.'); } catch (err) { toast(`Screenshot konnte nicht kopiert werden: ${err.message}`, true); }
});
} else {
btn('Screenshot öffnen', '', () => window.open(`bugs/${b.id}.png`, '_blank', 'noopener'));
}
}
if (b.status === 'offen') btn('Erledigt', 'ghost', () => run(() => api(`api/bugs/${b.id}`, { method: 'PUT', body: { status: 'erledigt' } }), 'Als erledigt markiert.'));
else btn('Wieder öffnen', 'ghost', () => run(() => api(`api/bugs/${b.id}`, { method: 'PUT', body: { status: 'offen' } })));
btn('Löschen', 'ghost danger', () => { if (confirm('Diesen Bug samt Screenshot löschen?')) run(() => api(`api/bugs/${b.id}`, { method: 'DELETE' }), 'Bug gelöscht.'); });
grow.appendChild(actions);
li.appendChild(grow);
return li;
}
function renderBugs() {
const all = state.data.bugs || [];
const open = all.filter((b) => b.status === 'offen').sort((a, b) => b.createdAt.localeCompare(a.createdAt));
const done = all.filter((b) => b.status !== 'offen').sort((a, b) => (b.resolvedAt || '').localeCompare(a.resolvedAt || ''));
$('#bugs-clipboard-hint').hidden = canCopyImages();
const list = $('#bug-list');
list.textContent = '';
if (open.length === 0) list.innerHTML = '<li class="hint">Keine offenen Bugs.</li>';
for (const b of open) list.appendChild(bugRow(b));
const doneList = $('#bug-list-done');
doneList.textContent = '';
for (const b of done) doneList.appendChild(bugRow(b));
doneList.hidden = !$('#bugs-show-done').checked || done.length === 0;
$('#bugs-toggle').hidden = done.length === 0;
}
$('#bugs-show-done').addEventListener('change', renderBugs);
// ---------- Start ----------
function renderAll() {
applyAuthUI();
renderCalendar();
if (isEditor()) {
renderPersons();
renderPhotoField();
renderAbsences();
renderRequests();
renderBugs();
renderSettings();
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
} else {
showView(location.hash.slice(1));
}
}
showView(location.hash.slice(1));
load().catch((err) => toast(`Laden fehlgeschlagen: ${err.message}`, true));