Betrachter klicken ihren Tag an, wählen ihren Namen, optional Wunschperson und Wunschtag, und schreiben eine Notiz; keine Anmeldung nötig, zehn Anfragen je Stunde und Adresse. Offene Anfragen markieren den Tag im Kalender. Bearbeiter sehen sie unter „Anfragen" mit Zähler und führen den Tausch aus (über Kreuz oder Übernahme), öffnen den Tag oder lehnen ab. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1154 lines
55 KiB
JavaScript
1154 lines
55 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', '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;
|
||
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', '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);
|
||
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,
|
||
} });
|
||
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); }
|
||
});
|
||
|
||
// ---------- Start ----------
|
||
function renderAll() {
|
||
applyAuthUI();
|
||
renderCalendar();
|
||
if (isEditor()) {
|
||
renderPersons();
|
||
renderPhotoField();
|
||
renderAbsences();
|
||
renderRequests();
|
||
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));
|