Reiter, Panel, Käfer-Beschriftungen und Doku sprechen von Bugs statt Fehlern. Nach dem Senden schließt sich das Panel sofort und eine Einblendung bestätigt die Meldung. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1475 lines
70 KiB
JavaScript
1475 lines
70 KiB
JavaScript
// 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`;
|
||
}
|
||
function openRequestDialog(date) {
|
||
const f = requestForm.elements;
|
||
requestForm.reset();
|
||
f.date.value = date || '';
|
||
fillRequestPersons(f.date.value);
|
||
$('#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 parts.push('Kein Wunschpartner genannt');
|
||
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) btn('Ausführen', 'primary', () => run(async () => {
|
||
const res = await api(`api/requests/${r.id}/execute`, { method: 'POST' });
|
||
return res;
|
||
}, 'Tausch ausgeführt. Nextcloud danach neu schreiben, damit die Termine stimmen.'));
|
||
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);
|
||
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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));
|