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>
This commit is contained in:
ferdi2goandClaude Fable 5.1 committed 2026-09-16 06:39:37 +00:00
commit 95756778d8
26 files changed
+4697

No files matched your search

+856
View File
@@ -0,0 +1,856 @@
// 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));
+244
View File
@@ -0,0 +1,244 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Rotatory – Morning-Meeting-Moderation</title>
<link rel="stylesheet" href="style.css">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%8E%99%3C/text%3E%3C/svg%3E">
</head>
<body>
<header class="topbar">
<h1><span aria-hidden="true">🎙</span> Rotatory</h1>
<nav aria-label="Bereiche">
<a href="#kalender" data-view="kalender">Kalender</a>
<a href="#personen" data-view="personen">Personen</a>
<a href="#abwesenheiten" data-view="abwesenheiten">Abwesenheiten</a>
<a href="#einstellungen" data-view="einstellungen">Einstellungen</a>
</nav>
</header>
<main id="main">
<!-- Kalender -->
<section id="view-kalender" class="view" aria-labelledby="cal-title">
<div class="toolbar">
<div class="month-nav">
<button type="button" id="prev-month" aria-label="Vorheriger Monat">‹</button>
<h2 id="cal-title">Monat</h2>
<button type="button" id="next-month" aria-label="Nächster Monat">›</button>
<button type="button" id="today-month" class="ghost">Heute</button>
</div>
<div class="actions">
<button type="button" id="open-plan" class="primary">Auslosen …</button>
<button type="button" id="open-nextcloud">Nextcloud …</button>
<a id="download-ics" class="button ghost" href="#" download>ICS</a>
</div>
</div>
<p id="cal-hint" class="hint" hidden></p>
<div class="calendar" aria-labelledby="cal-title">
<div class="weekdays" aria-hidden="true">
<div>Mo</div><div>Di</div><div>Mi</div><div>Do</div><div>Fr</div>
</div>
<div id="cal-grid" class="days"></div>
</div>
<div class="legend">
<span><span class="badge main">H</span> Hauptmoderation</span>
<span><span class="badge co">N</span> Nebenmoderation</span>
<span><span class="lock" aria-hidden="true">🔒</span> fixiert</span>
<span><span class="avatar empty small" aria-hidden="true">?</span> unbesetzt</span>
</div>
<section class="stats" aria-labelledby="stats-title">
<h3 id="stats-title">Einsätze in diesem Monat</h3>
<div id="stats"></div>
</section>
</section>
<!-- Personen -->
<section id="view-personen" class="view" hidden aria-labelledby="persons-title">
<h2 id="persons-title">Personen</h2>
<form id="person-form" class="card form-grid" autocomplete="off">
<input type="hidden" name="id">
<label>Vorname <input name="firstName" required maxlength="60"></label>
<label>Nachname <input name="lastName" required maxlength="60"></label>
<label>E-Mail (optional, für Kalendereinladungen) <input name="email" type="email" maxlength="120"></label>
<label>Notiz / Grund (optional) <input name="note" maxlength="300" placeholder="z. B. Azubi, nur Nebenmoderation"></label>
<fieldset class="checks">
<legend>Rolle in der Rotation</legend>
<label><input type="checkbox" name="canMain" checked> Hauptmoderation möglich</label>
<label><input type="checkbox" name="canCo" checked> Nebenmoderation möglich</label>
<label><input type="checkbox" name="active" checked> Aktiv (in der Belegschaft)</label>
</fieldset>
<fieldset class="checks">
<legend>Feste freie Wochentage (Teilzeit)</legend>
<label><input type="checkbox" name="freeDays" value="1"> Mo</label>
<label><input type="checkbox" name="freeDays" value="2"> Di</label>
<label><input type="checkbox" name="freeDays" value="3"> Mi</label>
<label><input type="checkbox" name="freeDays" value="4"> Do</label>
<label><input type="checkbox" name="freeDays" value="5"> Fr</label>
</fieldset>
<p class="hint">Beide Rollen abwählen = Nicht-Moderator (bleibt angelegt, wird nie ausgelost).</p>
<div class="form-actions">
<button type="submit" class="primary" id="person-submit">Person anlegen</button>
<button type="button" class="ghost" id="person-cancel" hidden>Abbrechen</button>
<span class="spacer"></span>
<button type="button" id="open-contacts">Aus Nextcloud importieren …</button>
</div>
</form>
<ul id="person-list" class="person-list"></ul>
<input type="file" id="photo-input" accept="image/*" hidden>
</section>
<!-- Abwesenheiten -->
<section id="view-abwesenheiten" class="view" hidden aria-labelledby="absences-title">
<h2 id="absences-title">Abwesenheiten</h2>
<form id="absence-form" class="card form-grid" autocomplete="off">
<label>Person <select name="personId" required></select></label>
<label>Art
<select name="type">
<option value="urlaub">Urlaub</option>
<option value="krankheit">Krankheit (geplant)</option>
<option value="frei">Frei (Gleittag, Teilzeit)</option>
<option value="sonstiges">Sonstiges</option>
</select>
</label>
<label>Von <input type="date" name="from" required></label>
<label>Bis <input type="date" name="to" required></label>
<label class="wide">Notiz (optional) <input name="note" maxlength="200"></label>
<div class="form-actions">
<button type="submit" class="primary">Abwesenheit eintragen</button>
</div>
</form>
<form id="absence-import" class="card form-grid" autocomplete="off">
<h3 class="wide">Aus Nextcloud-Kalender übernehmen</h3>
<label class="wide">Quellkalender (Termine wie „Vorname Nachname (Urlaub)", „… (krank)", „… (frei)")
<select name="calendarHref" required><option value="">– Kalender laden …</option></select>
</label>
<label>Von Monat <input type="month" name="from" required></label>
<label>Bis Monat <input type="month" name="to" required></label>
<p class="hint">Bereits importierte Einträge im Zeitraum werden ersetzt, manuelle bleiben. Namen, die keiner Person zugeordnet werden können, werden aufgelistet.</p>
<p id="absence-import-result" class="result wide" hidden></p>
<div class="form-actions">
<button type="submit" class="primary" id="absence-import-go">Importieren</button>
</div>
</form>
<p class="hint">Abwesenheiten wirken auf die nächste Auslosung. Bereits ausgeloste Tage werden markiert und lassen sich im Kalender neu auslosen.</p>
<label class="inline"><input type="checkbox" id="absence-show-past"> Vergangene anzeigen</label>
<ul id="absence-list" class="list"></ul>
</section>
<!-- Einstellungen -->
<section id="view-einstellungen" class="view" hidden aria-labelledby="settings-title">
<h2 id="settings-title">Einstellungen</h2>
<form id="settings-form" class="card form-grid" autocomplete="off">
<label>Uhrzeit des Meetings (leer = ganztägig) <input type="time" name="meetingTime"></label>
<label>Dauer in Minuten <input type="number" name="durationMinutes" min="5" max="480"></label>
<label>Bundesland (für Feiertage) <select name="bundesland"></select></label>
<label>Fairness-Fenster in Tagen <input type="number" name="historyWindowDays" min="0" max="365"></label>
<label>Kalendername in Nextcloud <input name="calendarDisplayName" maxlength="60"></label>
<label class="inline wide"><input type="checkbox" name="inviteAttendees"> Moderatoren als Teilnehmer eintragen (E-Mail nötig; Nextcloud verschickt dann Einladungen)</label>
<div class="form-actions"><button type="submit" class="primary">Speichern</button></div>
</form>
<h3>Feiertage und betriebsfreie Tage</h3>
<div class="card">
<form id="holiday-generate" class="row">
<label>Gesetzliche Feiertage erzeugen für <input type="number" name="year" min="2000" max="2100" required></label>
<button type="submit">Erzeugen</button>
</form>
<form id="holiday-add" class="row">
<label>Tag <input type="date" name="date" required></label>
<label>Bezeichnung <input name="name" maxlength="80" placeholder="z. B. Betriebsausflug"></label>
<button type="submit">Hinzufügen</button>
</form>
<ul id="holiday-list" class="list compact"></ul>
</div>
<h3>Nextcloud</h3>
<div class="card" id="nextcloud-status"></div>
</section>
</main>
<!-- Dialog: Tag bearbeiten -->
<dialog id="day-dialog">
<form method="dialog" class="dialog-form" id="day-form">
<h2 id="day-dialog-title">Tag</h2>
<p id="day-dialog-info" class="hint"></p>
<label>Hauptmoderation <select name="main"></select></label>
<label>Nebenmoderation <select name="co"></select></label>
<label class="inline"><input type="checkbox" name="locked"> Fixieren (bleibt bei jeder Auslosung erhalten)</label>
<label class="inline"><input type="checkbox" name="off"> Kein Meeting an diesem Tag</label>
<div class="form-actions">
<button type="button" id="day-reroll">🎲 Neu auslosen</button>
<button type="button" id="day-clear" class="ghost danger">Entfernen</button>
<span class="spacer"></span>
<button type="button" id="day-close" class="ghost">Abbrechen</button>
<button type="submit" class="primary">Speichern</button>
</div>
</form>
</dialog>
<!-- Dialog: Auslosen -->
<dialog id="plan-dialog">
<form method="dialog" class="dialog-form" id="plan-form">
<h2>Moderation auslosen</h2>
<label>Von Monat <input type="month" name="from" required></label>
<label>Bis Monat <input type="month" name="to" required></label>
<label class="inline"><input type="checkbox" name="overwrite"> Bereits ausgeloste, nicht fixierte Tage neu auslosen</label>
<p class="hint">Werktage ohne Feiertag. Abwesende werden übersprungen, niemand zwei Werktage in Folge, Einsätze möglichst gleich verteilt.</p>
<p id="plan-result" class="result" hidden></p>
<div class="form-actions">
<button type="button" id="plan-clear" class="ghost danger">Zeitraum leeren</button>
<span class="spacer"></span>
<button type="button" class="ghost" id="plan-close">Schließen</button>
<button type="submit" class="primary">Auslosen</button>
</div>
</form>
</dialog>
<!-- Dialog: Nextcloud -->
<dialog id="nextcloud-dialog">
<form method="dialog" class="dialog-form" id="nextcloud-form">
<h2>In Nextcloud schreiben</h2>
<p id="nextcloud-info" class="hint"></p>
<label>Zielkalender
<select name="target"><option value="">– Kalender laden …</option></select>
</label>
<label>Von Monat <input type="month" name="from" required></label>
<label>Bis Monat <input type="month" name="to" required></label>
<label class="inline"><input type="checkbox" name="removeForeign"> Andere Termine im Zeitraum aus dem Zielkalender entfernen (z. B. alte Morning-Meeting-Termine)</label>
<p class="hint">Je Werktag ein Termin. Wiederholtes Schreiben aktualisiert bestehende Termine; entfallene Tage werden im Kalender gelöscht.</p>
<p id="nextcloud-result" class="result" hidden></p>
<div class="form-actions">
<button type="button" id="nextcloud-test">Verbindung testen</button>
<span class="spacer"></span>
<button type="button" class="ghost" id="nextcloud-close">Schließen</button>
<button type="submit" class="primary" id="nextcloud-go">Go – schreiben</button>
</div>
</form>
</dialog>
<!-- Dialog: Kontakte importieren -->
<dialog id="contacts-dialog">
<form method="dialog" class="dialog-form" id="contacts-form">
<h2>Personen aus Nextcloud</h2>
<label>Adressbuch <select name="addressbook"></select></label>
<p id="contacts-info" class="hint">Lade Kontakte …</p>
<div class="contacts-tools">
<button type="button" class="ghost" id="contacts-all">Alle neuen wählen</button>
<button type="button" class="ghost" id="contacts-none">Keine</button>
</div>
<ul id="contacts-list" class="contacts-list"></ul>
<p id="contacts-result" class="result" hidden></p>
<div class="form-actions">
<span class="spacer"></span>
<button type="button" class="ghost" id="contacts-close">Schließen</button>
<button type="submit" class="primary" id="contacts-go">Ausgewählte anlegen</button>
</div>
</form>
</dialog>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script type="module" src="app.js"></script>
</body>
</html>
+204
View File
@@ -0,0 +1,204 @@
:root {
--bg: #f6f7fb;
--card: #ffffff;
--text: #1c2130;
--muted: #5d6578;
--line: #dde1ea;
--accent: #2f6fed;
--accent-text: #ffffff;
--main: #2f6fed;
--co: #e08a1e;
--danger: #c93b3b;
--holiday: #eef0f5;
--today: #fff6d6;
--warn: #b25e00;
--ok: #1d7a3e;
--radius: 10px;
--cell-min: 64px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171f;
--card: #1e222c;
--text: #e8eaf0;
--muted: #a0a7b8;
--line: #313746;
--holiday: #1a1d25;
--today: #3a3420;
--accent: #5b8dff;
--main: #5b8dff;
--co: #f0a23a;
--danger: #ff7b7b;
--warn: #f2b45a;
--ok: #5fcf7f;
}
}
* { box-sizing: border-box; }
html { color-scheme: light dark; }
body {
margin: 0;
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--text);
background: var(--bg);
}
h1, h2, h3 { margin: 0 0 .5rem; line-height: 1.2; }
h1 { font-size: 1.25rem; }
h2 { font-size: 1.2rem; }
h3 { font-size: 1rem; margin-top: 1.5rem; }
a { color: var(--accent); }
[hidden] { display: none !important; }
.topbar {
display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: center;
padding: .6rem 1rem; background: var(--card); border-bottom: 1px solid var(--line);
position: sticky; top: 0; z-index: 5;
}
.topbar nav { display: flex; gap: .25rem; flex-wrap: wrap; }
.topbar nav a {
text-decoration: none; color: var(--muted); padding: .35rem .7rem; border-radius: 999px; font-weight: 500;
}
.topbar nav a[aria-current="page"] { background: var(--accent); color: var(--accent-text); }
.topbar nav a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, .day:focus-visible {
outline: 3px solid var(--accent); outline-offset: 2px;
}
main { padding: 1rem; max-width: 1100px; margin: 0 auto; }
.view > h2 { margin-bottom: 1rem; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; margin-bottom: 1rem; }
.form-grid { display: grid; gap: .75rem; grid-template-columns: 1fr; }
@media (min-width: 640px) { .form-grid { grid-template-columns: 1fr 1fr; } .form-grid .wide, .form-grid fieldset, .form-grid .form-actions, .form-grid .hint { grid-column: 1 / -1; } }
label { display: flex; flex-direction: column; gap: .25rem; font-size: .9rem; color: var(--muted); }
label.inline { flex-direction: row; align-items: center; gap: .5rem; color: var(--text); }
input, select, button, .button {
font: inherit; border-radius: 8px; border: 1px solid var(--line); padding: .5rem .65rem; background: var(--card); color: var(--text);
min-height: 40px;
}
input[type="checkbox"] { min-height: 0; width: 1.1rem; height: 1.1rem; accent-color: var(--accent); }
/* Felder dürfen nie breiter werden als ihr Container – lange Kalendernamen in <select> sonst schon */
label > input:not([type="checkbox"]), label > select { width: 100%; min-width: 0; max-width: 100%; }
.form-grid > *, .dialog-form > *, .row > label { min-width: 0; }
.row > label { flex: 1 1 180px; }
.row > button { flex: 0 0 auto; }
fieldset.checks { border: 1px solid var(--line); border-radius: 8px; display: flex; flex-wrap: wrap; gap: .75rem 1.5rem; padding: .5rem .75rem .75rem; margin: 0; }
fieldset.checks legend { font-size: .85rem; color: var(--muted); padding: 0 .25rem; }
fieldset.checks label { flex-direction: row; align-items: center; gap: .4rem; color: var(--text); }
button, .button { cursor: pointer; font-weight: 500; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; gap: .35rem; }
button:hover, .button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
button.ghost, .button.ghost { background: transparent; }
button.danger { color: var(--danger); }
button:disabled { opacity: .5; cursor: wait; }
.form-actions { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
.form-actions .spacer { flex: 1; }
.row { display: flex; flex-wrap: wrap; gap: .5rem 1rem; align-items: end; margin-bottom: .75rem; }
.hint { color: var(--muted); font-size: .88rem; margin: .25rem 0 .75rem; }
.result { padding: .6rem .8rem; border-radius: 8px; background: var(--holiday); font-size: .9rem; white-space: pre-line; }
.result.error { color: var(--danger); }
/* Kalender */
.toolbar { display: flex; flex-wrap: wrap; gap: .75rem; justify-content: space-between; align-items: center; margin-bottom: .75rem; }
.month-nav { display: flex; align-items: center; gap: .4rem; }
.month-nav h2 { min-width: 11ch; text-align: center; margin: 0; }
.month-nav button { min-width: 40px; }
.actions { display: flex; gap: .4rem; flex-wrap: wrap; }
.calendar { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.weekdays, .days { display: grid; grid-template-columns: repeat(5, 1fr); }
.weekdays div { text-align: center; font-size: .8rem; color: var(--muted); padding: .4rem 0; border-bottom: 1px solid var(--line); text-transform: uppercase; letter-spacing: .04em; }
.day {
min-height: var(--cell-min); padding: .3rem; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line);
display: flex; flex-direction: column; gap: .25rem; text-align: left; background: transparent; border-radius: 0; color: inherit;
cursor: pointer; align-items: stretch; justify-content: flex-start;
}
.day:nth-child(5n) { border-right: 0; }
.day.outside { background: var(--holiday); cursor: default; opacity: .55; }
.day.holiday { background: var(--holiday); }
.day.today { background: var(--today); }
.day.past { opacity: .7; }
.day:hover:not(.outside) { border-color: var(--line); box-shadow: inset 0 0 0 2px var(--accent); }
.day .num { font-size: .8rem; color: var(--muted); display: flex; justify-content: space-between; align-items: center; }
.day .num .lock { font-size: .7rem; }
.day .label { font-size: .72rem; color: var(--muted); line-height: 1.2; overflow-wrap: anywhere; }
.day .label.off { color: var(--warn); font-weight: 500; }
.day .pair { display: flex; gap: .3rem; align-items: flex-end; justify-content: center; flex-wrap: wrap; }
.slot { display: flex; flex-direction: column; align-items: center; gap: .1rem; min-width: 0; }
.slot .name { font-size: .68rem; line-height: 1.1; text-align: center; max-width: 5.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.slot .name.absent { color: var(--danger); text-decoration: line-through; }
.avatar.absent { border-color: var(--danger); box-shadow: 0 0 0 2px var(--danger); }
.avatar {
width: 52px; height: 52px; border-radius: 50%; object-fit: cover; background: var(--holiday);
display: inline-flex; align-items: center; justify-content: center; font-weight: 600; color: #fff; font-size: 1rem;
border: 3px solid var(--main); position: relative; flex-shrink: 0;
}
.avatar.co { width: 42px; height: 42px; border-color: var(--co); font-size: .85rem; }
.avatar.empty { border-style: dashed; color: var(--muted); background: transparent; }
.avatar.small { width: 24px; height: 24px; border-width: 2px; font-size: .7rem; }
.avatar.plain { border-color: var(--line); }
.slot .avatar-wrap { position: relative; display: inline-block; }
.badge {
display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 50%;
font-size: .65rem; font-weight: 700; color: #fff; background: var(--main);
}
.badge.co { background: var(--co); }
.slot .badge { position: absolute; bottom: -2px; right: -4px; border: 2px solid var(--card); width: 20px; height: 20px; }
.legend { display: flex; flex-wrap: wrap; gap: .5rem 1.25rem; margin: .75rem 0; font-size: .85rem; color: var(--muted); align-items: center; }
.legend > span { display: inline-flex; align-items: center; gap: .35rem; }
.stats { margin-top: .5rem; }
.stats h3 { margin-top: .5rem; }
#stats { display: flex; flex-wrap: wrap; gap: .5rem; }
.chip { display: inline-flex; align-items: center; gap: .4rem; padding: .25rem .6rem .25rem .3rem; border: 1px solid var(--line); border-radius: 999px; background: var(--card); font-size: .85rem; }
.chip .avatar { width: 26px; height: 26px; border-width: 2px; font-size: .7rem; }
.chip .count { color: var(--muted); }
@media (max-width: 520px) {
:root { --cell-min: 78px; }
main { padding: .5rem; }
.avatar { width: 34px; height: 34px; border-width: 2px; font-size: .75rem; }
.avatar.co { width: 28px; height: 28px; font-size: .65rem; }
.slot .badge { width: 15px; height: 15px; font-size: .55rem; }
.slot .name { display: none; }
.day { padding: .2rem; }
.day .pair { flex-direction: column; align-items: center; gap: .15rem; }
}
/* Listen */
.list, .person-list { list-style: none; padding: 0; margin: 0; display: grid; gap: .5rem; }
.list li, .person-list li {
display: flex; align-items: center; gap: .75rem; padding: .6rem .8rem; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); flex-wrap: wrap;
}
.list.compact li { padding: .35rem .6rem; }
.person-list .avatar { width: 56px; height: 56px; cursor: pointer; }
.person-list .avatar.plain:hover { border-color: var(--accent); }
.person-list .who { flex: 1; min-width: 160px; }
.person-list .who strong { display: block; }
.person-list .who small, .list small { color: var(--muted); }
.tags { display: flex; gap: .3rem; flex-wrap: wrap; margin-top: .2rem; }
.tag { font-size: .72rem; padding: .1rem .5rem; border-radius: 999px; background: var(--holiday); color: var(--muted); }
.tag.main { background: var(--main); color: #fff; }
.tag.co { background: var(--co); color: #fff; }
.tag.off { background: var(--danger); color: #fff; }
.list li .grow { flex: 1; min-width: 160px; }
.list li button, .person-list li button { min-height: 34px; padding: .3rem .6rem; font-size: .85rem; }
.person-list li.inactive { opacity: .6; }
.type-urlaub { color: var(--ok); } .type-krankheit { color: var(--danger); } .type-frei { color: var(--accent); } .type-sonstiges { color: var(--muted); }
.contacts-list { list-style: none; margin: 0; padding: 0; max-height: 50vh; overflow: auto; border: 1px solid var(--line); border-radius: 8px; }
.contacts-list li { display: flex; align-items: center; gap: .6rem; padding: .4rem .6rem; border-bottom: 1px solid var(--line); }
.contacts-list li:last-child { border-bottom: 0; }
.contacts-list li.exists { opacity: .55; }
.contacts-list label { flex-direction: row; align-items: center; gap: .6rem; color: var(--text); flex: 1; cursor: pointer; }
.contacts-list small { color: var(--muted); }
.contacts-tools { display: flex; gap: .5rem; }
.form-grid h3 { margin: 0; }
/* Dialoge */
dialog { border: 1px solid var(--line); border-radius: var(--radius); padding: 0; background: var(--card); color: var(--text); width: min(92vw, 520px); max-width: calc(100vw - 2rem); box-sizing: border-box; }
dialog::backdrop { background: rgba(0,0,0,.45); }
.dialog-form { display: grid; gap: .75rem; padding: 1.25rem; }
.dialog-form h2 { margin: 0; }
.dialog-form .form-actions { margin-top: .5rem; }
.toast { position: fixed; bottom: calc(1rem + env(safe-area-inset-bottom, 0px)); left: 50%; transform: translateX(-50%); background: var(--text); color: var(--bg); padding: .6rem 1rem; border-radius: 999px; font-size: .9rem; z-index: 20; max-width: 90vw; }
.toast.error { background: var(--danger); color: #fff; }
.status-ok { color: var(--ok); } .status-warn { color: var(--warn); }
code { background: var(--holiday); padding: .1rem .3rem; border-radius: 4px; font-size: .85em; }