Files
Rotatory/public/app.js
T
ferdi2goandClaude Fable 5.1 95756778d8 Rotatory anlegen: Morning-Meeting-Moderation auslosen und nach Nextcloud schreiben
Express-App mit JSON-Speicher unter VCH_DATA_DIR, Monatskalender mit Fotos,
Personen mit Rollen und Teilzeit-Freitagen, Abwesenheiten, Feiertage je
Bundesland, faire Zufallsauslosung mit Fixieren und Neu-Auslosen.

Nextcloud: CalDAV-Discovery, Zielkalender wählbar, Termine je Werktag
schreiben und aktualisieren, Abwesenheiten aus Kalender importieren,
Personen samt Fotos aus dem Systemadressbuch übernehmen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 06:39:37 +00:00

857 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Rotatory – Frontend ohne Abhängigkeiten. Alle Pfade relativ (Proxy-tauglich).
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const state = { data: null, month: startOfMonth(new Date()) };
// ---------- Hilfen ----------
function pad(n) { return String(n).padStart(2, '0'); }
function iso(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }
function startOfMonth(d) { return new Date(d.getFullYear(), d.getMonth(), 1); }
function monthKey(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`; }
function monthRange(fromKey, toKey) {
const [fy, fm] = fromKey.split('-').map(Number);
const [ty, tm] = toKey.split('-').map(Number);
return { from: `${fy}-${pad(fm)}-01`, to: iso(new Date(ty, tm, 0)) };
}
function todayIso() { return iso(new Date()); }
function fmtDate(isoStr) {
const [y, m, d] = isoStr.split('-');
return `${d}.${m}.${y}`;
}
function fmtLong(isoStr) {
return new Date(isoStr + 'T12:00:00').toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
}
const 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) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
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 colorFor(id) {
let h = 0;
for (const c of id) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return `hsl(${h % 360} 55% 45%)`;
}
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.style.background = colorFor(p.id);
e.textContent = initials(p);
e.setAttribute('role', 'img');
e.setAttribute('aria-label', fullName(p));
return e;
}
// ---------- Navigation ----------
function showView(name) {
const known = ['kalender', 'personen', 'abwesenheiten', 'einstellungen'];
if (!known.includes(name)) name = 'kalender';
if (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 cell = document.createElement(outside ? 'div' : 'button');
cell.className = 'day';
if (!outside) { cell.type = 'button'; cell.dataset.date = date; }
if (outside) cell.classList.add('outside');
if (date === today) cell.classList.add('today');
if (date < today) cell.classList.add('past');
const num = document.createElement('div');
num.className = 'num';
num.textContent = cur.getDate();
cell.appendChild(num);
if (outside) { grid.appendChild(cell); continue; }
const hol = holidayFor(date);
const a = assignments[date];
if (hol) {
cell.classList.add('holiday');
const l = document.createElement('div');
l.className = 'label';
l.textContent = hol.name;
cell.appendChild(l);
cell.setAttribute('aria-label', `${fmtLong(date)}, ${hol.name}`);
} else if (a && a.off) {
const l = document.createElement('div');
l.className = 'label off';
l.textContent = 'Kein Meeting';
cell.appendChild(l);
cell.setAttribute('aria-label', `${fmtLong(date)}, kein Meeting`);
} else if (a) {
planned += 1;
if (a.locked) { const lock = document.createElement('span'); lock.className = 'lock'; lock.textContent = '🔒'; lock.title = 'fixiert'; num.appendChild(lock); }
const pair = document.createElement('div');
pair.className = 'pair';
const main = personById(a.main);
const co = personById(a.co);
if (!main || !co) unfilled += 1;
pair.appendChild(slotEl(main, 'main', date));
pair.appendChild(slotEl(co, 'co', date));
cell.appendChild(pair);
const absentNote = (p) => (p && isAbsent(p.id, date) ? ' (abwesend!)' : '');
cell.setAttribute('aria-label', `${fmtLong(date)}: Haupt ${fullName(main)}${absentNote(main)}, Neben ${fullName(co)}${absentNote(co)}${a.locked ? ', fixiert' : ''}`);
} else {
cell.setAttribute('aria-label', `${fmtLong(date)}, noch nicht ausgelost`);
}
grid.appendChild(cell);
}
const hint = $('#cal-hint');
if (persons.length === 0) { hint.textContent = 'Noch keine Personen angelegt – zuerst unter „Personen" die Belegschaft anlegen.'; hint.hidden = false; }
else if (planned === 0) { hint.textContent = 'Für diesen Monat ist noch nichts ausgelost. Mit „Auslosen …" starten.'; hint.hidden = false; }
else if (unfilled > 0) { hint.textContent = `${unfilled} Tag(e) sind nicht vollständig besetzt – im Kalender anklicken und manuell zuweisen oder neu auslosen.`; hint.hidden = false; }
else hint.hidden = true;
const range = monthRange(monthKey(d), monthKey(d));
$('#download-ics').href = `api/export.ics?from=${range.from}&to=${range.to}`;
renderStats(range);
}
function slotEl(p, role, date) {
const slot = document.createElement('div');
slot.className = 'slot';
const wrap = document.createElement('span');
wrap.className = 'avatar-wrap';
const absent = Boolean(p && isAbsent(p.id, date));
wrap.appendChild(avatarEl(p, `${role === 'co' ? 'co' : ''}${absent ? ' absent' : ''}`));
const badge = document.createElement('span');
badge.className = `badge ${role}`;
badge.textContent = role === 'main' ? 'H' : 'N';
badge.setAttribute('aria-hidden', 'true');
wrap.appendChild(badge);
slot.appendChild(wrap);
const name = document.createElement('span');
name.className = 'name';
name.textContent = shortName(p);
name.title = fullName(p);
if (absent) { name.classList.add('absent'); name.title += ' – abwesend!'; }
slot.appendChild(name);
return slot;
}
function renderStats(range) {
const counts = {};
for (const [date, a] of Object.entries(state.data.assignments)) {
if (date < range.from || date > range.to || a.off) continue;
for (const [role, id] of [['main', a.main], ['co', a.co]]) {
if (!id) continue;
counts[id] = counts[id] || { main: 0, co: 0 };
counts[id][role] += 1;
}
}
const box = $('#stats');
box.textContent = '';
const rows = state.data.persons
.filter((p) => p.active !== false && (p.canMain || p.canCo || counts[p.id]))
.map((p) => ({ p, c: counts[p.id] || { main: 0, co: 0 } }))
.sort((a, b) => (b.c.main + b.c.co) - (a.c.main + a.c.co) || fullName(a.p).localeCompare(fullName(b.p)));
if (rows.length === 0) { box.innerHTML = '<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) openDayDialog(cell.dataset.date);
});
// ---------- Tag-Dialog ----------
const dayDialog = $('#day-dialog');
const dayForm = $('#day-form');
let dayDialogDate = null;
function fillPersonSelect(select, role, date, selected) {
select.textContent = '';
const none = document.createElement('option');
none.value = '';
none.textContent = '– unbesetzt –';
select.appendChild(none);
for (const p of state.data.persons.filter((x) => x.active !== false).sort((a, b) => fullName(a).localeCompare(fullName(b)))) {
const o = document.createElement('option');
o.value = p.id;
const flags = [];
if (role === 'main' && !p.canMain) flags.push('keine Hauptmoderation');
if (role === 'co' && !p.canCo) flags.push('keine Nebenmoderation');
if (isAbsent(p.id, date)) flags.push('abwesend');
o.textContent = fullName(p) + (flags.length ? ` (${flags.join(', ')})` : '');
select.appendChild(o);
}
select.value = selected || '';
}
function openDayDialog(date) {
dayDialogDate = date;
const a = state.data.assignments[date] || {};
const hol = holidayFor(date);
$('#day-dialog-title').textContent = fmtLong(date);
$('#day-dialog-info').textContent = hol ? `${hol.name} – an diesem Tag findet kein Meeting statt. Der Feiertag lässt sich unter Einstellungen entfernen.` : '';
fillPersonSelect(dayForm.elements.main, 'main', date, a.main);
fillPersonSelect(dayForm.elements.co, 'co', date, a.co);
dayForm.elements.locked.checked = Boolean(a.locked);
dayForm.elements.off.checked = Boolean(a.off);
for (const el of [dayForm.elements.main, dayForm.elements.co, dayForm.elements.locked, dayForm.elements.off, $('#day-reroll'), $('#day-clear'), dayForm.querySelector('button[type="submit"]')]) el.disabled = Boolean(hol);
dayDialog.showModal();
}
dayForm.addEventListener('submit', async (e) => {
e.preventDefault();
const f = dayForm.elements;
try {
await api(`api/assignments/${dayDialogDate}`, { method: 'PUT', body: { main: f.main.value || null, co: f.co.value || null, locked: f.locked.checked, off: f.off.checked } });
dayDialog.close();
await load();
toast('Tag gespeichert.');
} catch (err) { toast(err.message, true); }
});
$('#day-close').addEventListener('click', () => dayDialog.close());
$('#day-reroll').addEventListener('click', async () => {
try {
// Nur ein Vorschlag – gespeichert wird erst mit „Speichern"
const a = await api(`api/assignments/${dayDialogDate}/reroll`, { method: 'POST', body: { preview: true } });
dayForm.elements.main.value = a.main || '';
dayForm.elements.co.value = a.co || '';
dayForm.elements.off.checked = false;
toast('Vorschlag ausgelost – mit „Speichern" übernehmen.');
} catch (err) { toast(err.message, true); }
});
$('#day-clear').addEventListener('click', async () => {
try {
await api(`api/assignments/${dayDialogDate}`, { method: 'PUT', body: { main: null, co: null, locked: false, off: false } });
dayDialog.close();
await load();
} catch (err) { toast(err.message, true); }
});
// ---------- Auslosen-Dialog ----------
const planDialog = $('#plan-dialog');
const planForm = $('#plan-form');
$('#open-plan').addEventListener('click', () => {
const next = new Date(state.month.getFullYear(), state.month.getMonth() + 2, 1);
planForm.elements.from.value = monthKey(state.month);
planForm.elements.to.value = monthKey(next);
$('#plan-result').hidden = true;
planDialog.showModal();
});
$('#plan-close').addEventListener('click', () => planDialog.close());
planForm.addEventListener('submit', async (e) => {
e.preventDefault();
const r = monthRange(planForm.elements.from.value, planForm.elements.to.value);
const out = $('#plan-result');
try {
const res = await api('api/schedule/generate', { method: 'POST', body: { ...r, overwrite: planForm.elements.overwrite.checked } });
await load();
out.className = 'result';
out.textContent = `${res.planned} Werktage geplant.` + (res.unfilled.length ? `\n${res.unfilled.length} Tag(e) konnten nicht voll besetzt werden: ${res.unfilled.map(fmtDate).join(', ')}` : ' Alle Tage besetzt.');
out.hidden = false;
} catch (err) { out.className = 'result error'; out.textContent = err.message; out.hidden = false; }
});
$('#plan-clear').addEventListener('click', async () => {
const r = monthRange(planForm.elements.from.value, planForm.elements.to.value);
if (!confirm(`Alle nicht fixierten Zuteilungen von ${fmtDate(r.from)} bis ${fmtDate(r.to)} löschen?`)) return;
try {
await api('api/schedule/clear', { method: 'POST', body: r });
await load();
$('#plan-result').className = 'result';
$('#plan-result').textContent = 'Zeitraum geleert.';
$('#plan-result').hidden = false;
} catch (err) { toast(err.message, true); }
});
// ---------- Nextcloud: Kalenderliste ----------
let calendarsPromise = null;
function loadCalendars(force = false) {
if (!calendarsPromise || force) calendarsPromise = api('api/nextcloud/calendars').catch((err) => { calendarsPromise = null; throw err; });
return calendarsPromise;
}
function calendarLabel(c) {
// Nextcloud hängt bei geteilten Kalendern den Besitzer schon an den Namen an
return `${c.name}${c.writable ? '' : ' – nur lesen'}`;
}
/** Select mit Kalendern füllen; onlyWritable filtert, extra fügt eine Option vorne an. */
async function fillCalendarSelect(select, { selected = '', onlyWritable = false, extra = null } = {}) {
select.textContent = '';
if (!state.data.nextcloud.configured) { select.appendChild(new Option('Nextcloud nicht konfiguriert', '')); select.disabled = true; return []; }
select.disabled = true;
select.appendChild(new Option('Lade Kalender …', ''));
try {
const { calendars } = await loadCalendars();
select.textContent = '';
if (extra) select.appendChild(new Option(extra.label, extra.value));
const list = calendars.filter((c) => !onlyWritable || c.writable);
for (const c of list) select.appendChild(new Option(calendarLabel(c), c.href));
if (selected && list.some((c) => c.href === selected)) select.value = selected;
else if (!extra && list.length) select.value = list[0].href;
select.disabled = false;
return list;
} catch (err) {
select.textContent = '';
select.appendChild(new Option(`Fehler: ${err.message}`, ''));
return [];
}
}
function selectedCalendarName(select) {
return select.selectedOptions[0]?.textContent || '';
}
// ---------- Nextcloud-Dialog ----------
const ncDialog = $('#nextcloud-dialog');
const ncForm = $('#nextcloud-form');
$('#open-nextcloud').addEventListener('click', async () => {
const nc = state.data.nextcloud;
const next = new Date(state.month.getFullYear(), state.month.getMonth() + 2, 1);
ncForm.elements.from.value = monthKey(state.month);
ncForm.elements.to.value = monthKey(next);
ncForm.elements.removeForeign.checked = false;
$('#nextcloud-info').textContent = nc.configured
? `${nc.baseUrl} · angemeldet als ${nc.user}`
: 'Nextcloud ist nicht konfiguriert. NEXTCLOUD_URL, NEXTCLOUD_USER und NEXTCLOUD_APP_PASSWORD als Umgebungsvariablen setzen.';
$('#nextcloud-go').disabled = !nc.configured;
$('#nextcloud-test').disabled = !nc.configured;
$('#nextcloud-result').hidden = true;
ncDialog.showModal();
const list = await fillCalendarSelect(ncForm.elements.target, {
selected: state.data.settings.targetCalendarHref,
onlyWritable: true,
extra: { label: `Neuen Kalender „${state.data.settings.calendarDisplayName}" anlegen (${nc.calendar})`, value: '' },
});
// Noch kein Ziel gewählt: vorhandenen Morning-Meeting-Kalender vorschlagen
if (!state.data.settings.targetCalendarHref) {
const guess = list.find((c) => /morning|meeting|moderation/i.test(c.name));
if (guess) ncForm.elements.target.value = guess.href;
}
});
ncForm.elements.target.addEventListener('change', async () => {
const sel = ncForm.elements.target;
try {
await api('api/settings', { method: 'PUT', body: { targetCalendarHref: sel.value, targetCalendarName: sel.value ? selectedCalendarName(sel) : '' } });
state.data.settings.targetCalendarHref = sel.value;
state.data.settings.targetCalendarName = sel.value ? selectedCalendarName(sel) : '';
renderSettings();
} catch (err) { toast(err.message, true); }
});
$('#nextcloud-close').addEventListener('click', () => ncDialog.close());
$('#nextcloud-test').addEventListener('click', async () => {
const out = $('#nextcloud-result');
const btn = $('#nextcloud-test');
btn.disabled = true;
try {
const r = await api('api/nextcloud/test', { method: 'POST' });
out.className = 'result';
const writable = r.calendars.filter((c) => c.writable).length;
out.textContent = `Verbindung OK – angemeldet als ${r.displayName || r.userId} (ID ${r.userId}).\n${r.calendars.length} Kalender sichtbar, ${writable} davon beschreibbar.`;
await fillCalendarSelect(ncForm.elements.target, { selected: ncForm.elements.target.value || state.data.settings.targetCalendarHref, onlyWritable: true, extra: { label: `Neuen Kalender „${state.data.settings.calendarDisplayName}" anlegen`, value: '' } });
} catch (err) { out.className = 'result error'; out.textContent = err.message; }
out.hidden = false;
btn.disabled = false;
});
ncForm.addEventListener('submit', async (e) => {
e.preventDefault();
const r = monthRange(ncForm.elements.from.value, ncForm.elements.to.value);
const out = $('#nextcloud-result');
const btn = $('#nextcloud-go');
btn.disabled = true;
out.className = 'result';
out.textContent = 'Schreibe Termine …';
out.hidden = false;
try {
const sel = ncForm.elements.target;
const res = await api('api/nextcloud/sync', { method: 'POST', body: { ...r, removeForeign: ncForm.elements.removeForeign.checked, targetCalendarHref: sel.value, targetCalendarName: sel.value ? selectedCalendarName(sel) : '' } });
await load();
const parts = [`${res.created ? 'Kalender angelegt. ' : ''}${res.written} Termin(e) geschrieben, ${res.removed} entfernt.`];
if (res.moved) parts.push(`${res.moved} Termin(e) aus dem vorherigen Zielkalender entfernt.`);
if (res.foreignRemoved) parts.push(`${res.foreignRemoved} fremde Termin(e) im Zeitraum gelöscht.`);
else if (res.foreign) parts.push(`Hinweis: ${res.foreign} fremde Termin(e) im Zeitraum vorhanden (nicht entfernt).`);
if (res.foreignRecurring) parts.push(`${res.foreignRecurring} wiederkehrende fremde Serie(n) bleiben unangetastet – bei Bedarf in Nextcloud löschen.`);
if (res.errors.length) parts.push(`Fehler:\n${res.errors.join('\n')}`);
out.textContent = parts.join('\n');
if (res.errors.length) out.className = 'result error';
} catch (err) { out.className = 'result error'; out.textContent = err.message; }
btn.disabled = false;
});
// ---------- Personen ----------
const personForm = $('#person-form');
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr'];
function renderPersons() {
const list = $('#person-list');
list.textContent = '';
const persons = [...state.data.persons].sort((a, b) => fullName(a).localeCompare(fullName(b)));
if (persons.length === 0) { list.innerHTML = '<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 = 'Foto hochladen (Klick)';
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);
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; }
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));
$('#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 = '';
$('#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 {
if (f.id.value) await api(`api/persons/${f.id.value}`, { method: 'PUT', body });
else await api('api/persons', { method: 'POST', body });
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.skipped ? ` ${res.skipped} übersprungen.` : ''}`;
await loadContacts(contactsForm.elements.addressbook.value);
} catch (err) { out.className = 'result error'; out.textContent = err.message; }
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 || !photoTarget) 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 off';
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 danger';
del.textContent = 'Löschen';
del.addEventListener('click', () => run(() => api(`api/absences/${a.id}`, { method: 'DELETE' }), 'Abwesenheit gelöscht.'));
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 danger';
del.textContent = 'Entfernen';
del.addEventListener('click', () => run(() => api(`api/holidays/${h.date}`, { method: 'DELETE' }), 'Eintrag entfernt.'));
li.appendChild(del);
list.appendChild(li);
}
const nc = state.data.nextcloud;
const box = $('#nextcloud-status');
box.innerHTML = nc.configured
? `<p class="status-ok">Konfiguriert: <code>${escapeHtml(nc.baseUrl)}</code>, angemeldet als <code>${escapeHtml(nc.user)}</code>.</p>`
+ `<p>Zielkalender: <strong>${s.targetCalendarHref ? escapeHtml(s.targetCalendarName || s.targetCalendarHref) : `neuer Kalender „${escapeHtml(s.calendarDisplayName)}" (${escapeHtml(nc.calendar)})`}</strong> – wählbar im Dialog „Nextcloud …" im Kalender.</p>`
+ `<p>Quellkalender für Abwesenheiten: <strong>${s.absenceCalendarHref ? escapeHtml(s.absenceCalendarName || s.absenceCalendarHref) : 'noch keiner'}</strong> – wählbar unter „Abwesenheiten".</p>`
: `<p class="status-warn">Nicht konfiguriert.</p><p class="hint">Umgebungsvariablen setzen: <code>NEXTCLOUD_URL</code>, <code>NEXTCLOUD_USER</code>, <code>NEXTCLOUD_APP_PASSWORD</code> (App-Passwort aus Nextcloud → Einstellungen → Sicherheit), optional <code>NEXTCLOUD_CALENDAR</code>. Bis dahin steht der ICS-Download zur Verfügung.</p>`;
}
function escapeHtml(s) { return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); }
settingsForm.addEventListener('submit', async (e) => {
e.preventDefault();
const f = settingsForm.elements;
try {
await api('api/settings', { method: 'PUT', body: {
meetingTime: f.meetingTime.value, durationMinutes: Number(f.durationMinutes.value), bundesland: f.bundesland.value,
historyWindowDays: Number(f.historyWindowDays.value), calendarDisplayName: f.calendarDisplayName.value, inviteAttendees: f.inviteAttendees.checked,
} });
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() {
renderCalendar();
renderPersons();
renderAbsences();
renderSettings();
if (!$('#view-abwesenheiten').hidden) prepareAbsenceImport();
}
showView(location.hash.slice(1));
load().catch((err) => toast(`Laden fehlgeschlagen: ${err.message}`, true));