/* =================================================================== Aufgabenbrett. Der Server entscheidet, wer was sehen und ändern darf -- diese Datei zeigt nur an. Was hier ausgeblendet wird, ist Bequemlichkeit, kein Schutz. =================================================================== */ (() => { 'use strict'; /* DogFather und Manager haben dieselben Rechte -- die Unterscheidung macht nur die Personenverwaltung, und die prueft der Server. */ const LEITUNG = new Set(['admin', 'manager']); const SPALTEN = [ { status: 'offen', name: 'Offen' }, { status: 'arbeit', name: 'In Arbeit' }, { status: 'review', name: 'Review' }, { status: 'erledigt', name: 'Erledigt' }, ]; const WEITER = { offen: 'arbeit', arbeit: 'review', review: 'erledigt' }; const ZURUECK = { arbeit: 'offen', review: 'arbeit', erledigt: 'review' }; const $ = (id) => document.getElementById(id); const heute = new Date().toISOString().slice(0, 10); let ich = null; /* Texte werden ausschließlich über textContent gesetzt, nie über innerHTML -- ein Aufgabentitel darf keine Auszeichnung einschleusen. */ const el = (tag, klasse, text) => { const k = document.createElement(tag); if (klasse) k.className = klasse; if (text !== undefined) k.textContent = text; return k; }; const melde = (text) => { $('fehler').textContent = text || ''; }; async function hole(pfad, optionen = {}) { const antwort = await fetch(pfad, { credentials: 'same-origin', ...optionen }); if (antwort.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } return antwort; } /* ---------- Brett zeichnen ------------------------------------------ */ function datumHuebsch(iso) { const [j, m, t] = iso.split('-'); return `${t}.${m}.${j.slice(2)}`; } /* ---------- Rückmeldungen ------------------------------------------------ Direkt an der Karte, nicht in einem Fenster: Man liest sie im Zusammenhang mit der Aufgabe, sonst fehlt beim Antworten die Hälfte. */ const zeitpunkt = (iso) => { if (!iso) return ''; const d = new Date(iso); const p = (n) => String(n).padStart(2, '0'); return `${p(d.getDate())}.${p(d.getMonth() + 1)}. · ${p(d.getHours())}:${p(d.getMinutes())}`; }; async function rueckmeldungen(karteEl, a, knopfEl) { const vorhanden = karteEl.querySelector('.rueck'); if (vorhanden) { vorhanden.remove(); delete knopfEl.dataset.offen; return; } knopfEl.dataset.offen = 'ja'; const box = el('div', 'rueck'); box.append(el('p', 'leise', 'Wird geladen …')); karteEl.append(box); let daten; try { const antwort = await hole(`/workspace/api/aufgaben/${a.id}/notizen`); if (!antwort.ok) { box.textContent = ''; box.append(el('p', 'fehler', 'Konnte nicht geladen werden.')); return; } daten = await antwort.json(); } catch { return; } box.textContent = ''; const liste = el('div', 'rueck__liste'); if (!daten.notizen.length) liste.append(el('p', 'leise', 'Noch nichts besprochen.')); for (const n of daten.notizen) { const z = el('div', 'rueck__eintrag'); const kopf = el('p', 'rueck__kopf'); kopf.append(el('span', 'rueck__wer', n.von || 'Unbekannt')); kopf.append(el('span', 'rueck__wann', zeitpunkt(n.erstellt))); /* Die eigene Rückmeldung darf jeder zurücknehmen, fremde nur die Leitung -- wer sich vertippt hat, soll das nicht beantragen müssen. */ if (n.person_id === daten.ich || daten.darf_alles_loeschen) { const weg = el('button', 'rueck__weg', '×'); weg.type = 'button'; weg.title = 'Rückmeldung entfernen'; weg.setAttribute('aria-label', 'Rückmeldung entfernen'); weg.addEventListener('click', async () => { if (!confirm('Diese Rückmeldung entfernen?')) return; const r = await hole('/workspace/api/notizen/' + n.id, { method: 'DELETE' }); if (!r.ok) { melde('Entfernen hat nicht geklappt.'); return; } await laden(); }); kopf.append(weg); } z.append(kopf, el('p', 'rueck__text', n.text)); liste.append(z); } box.append(liste); const form = el('form', 'rueck__form'); const feld = el('textarea', 'rueck__feld'); feld.rows = 2; feld.maxLength = 2000; feld.placeholder = 'Rückmeldung schreiben …'; const senden = el('button', 'knopf knopf--klein', 'Abschicken'); senden.type = 'submit'; form.append(feld, senden); form.addEventListener('submit', async (e) => { e.preventDefault(); const text = feld.value.trim(); if (text.length < 2) return; senden.disabled = true; try { const r = await hole(`/workspace/api/aufgaben/${a.id}/notizen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), }); if (!r.ok) { melde((await r.json().catch(() => ({}))).fehler || 'Ging nicht.'); return; } await laden(); } catch { /* umgeleitet */ } finally { senden.disabled = false; } }); box.append(form); feld.focus(); } function karte(a) { const k = el('article', 'karte'); k.dataset.prio = a.prioritaet; const kopf = el('div', 'karte__kopf'); kopf.append(el('h3', 'karte__titel', a.titel)); const stift = el('button', 'karte__stift', 'bearbeiten'); stift.type = 'button'; stift.setAttribute('aria-label', `${a.titel} bearbeiten`); stift.addEventListener('click', () => dialogOeffnen(a)); kopf.append(stift); k.append(kopf); if (a.beschreibung) k.append(el('p', 'karte__text', a.beschreibung)); const zeile = el('div', 'karte__zeile'); if (a.frist) { const faellig = a.status !== 'erledigt' && a.frist < heute; const f = el('span', 'karte__frist' + (faellig ? ' karte__frist--faellig' : ''), (faellig ? 'überfällig ' : 'bis ') + datumHuebsch(a.frist)); zeile.append(f); } if (a.verantwortlich_name) zeile.append(el('span', null, '· ' + a.verantwortlich_name)); if (LEITUNG.has(ich.rolle) && a.creator_name && a.creator_name !== a.verantwortlich_name) { zeile.append(el('span', null, '· Bereich ' + a.creator_name)); } if (zeile.childElementCount) k.append(zeile); const schritte = el('div', 'karte__schritte'); if (ZURUECK[a.status]) schritte.append(knopf('◀ zurück', a, ZURUECK[a.status])); if (WEITER[a.status]) schritte.append(knopf(beschriftung(a.status), a, WEITER[a.status])); if (schritte.childElementCount) k.append(schritte); /* Rückmeldungen ("Aufgaben & Feedback" aus dem Konzept). Der Knopf zeigt gleich die Anzahl -- so sieht man ohne Aufklappen, wo etwas besprochen wurde. */ const rueck = el('button', 'karte__rueck'); rueck.type = 'button'; rueck.dataset.hat = a.notizen ? 'ja' : 'nein'; const blase = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); blase.setAttribute('viewBox', '0 0 24 24'); blase.setAttribute('class', 'karte__blase'); blase.setAttribute('aria-hidden', 'true'); blase.innerHTML = ''; rueck.append(blase, el('span', null, a.notizen ? `${a.notizen} ${a.notizen === 1 ? 'Rückmeldung' : 'Rückmeldungen'}` : 'Rückmeldung schreiben')); rueck.addEventListener('click', () => rueckmeldungen(k, a, rueck)); k.append(rueck); return k; } const beschriftung = (status) => ({ offen: 'starten ▶', arbeit: 'zum Review ▶', review: 'erledigt ▶', })[status]; function knopf(text, aufgabe, neuerStatus) { const b = el('button', 'schritt', text); b.type = 'button'; b.addEventListener('click', async () => { b.disabled = true; melde(''); try { const antwort = await hole(`/workspace/api/aufgaben/${aufgabe.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: neuerStatus }), }); if (!antwort.ok) { const d = await antwort.json().catch(() => ({})); melde(d.fehler === 'nicht_erlaubt' ? 'Diese Aufgabe darfst du nicht ändern.' : 'Ändern hat nicht geklappt.'); b.disabled = false; return; } await laden(); } catch { b.disabled = false; } }); return b; } function zeichne(aufgaben) { const brett = $('brett'); brett.textContent = ''; brett.setAttribute('aria-busy', 'false'); for (const s of SPALTEN) { const eigene = aufgaben.filter((a) => a.status === s.status); const spalte = el('section', 'spalte'); spalte.dataset.status = s.status; const kopf = el('h2', 'spalte__kopf'); kopf.append(el('span', 'spalte__punkt'), el('span', null, s.name), el('span', 'spalte__zahl', String(eigene.length))); spalte.append(kopf); if (!eigene.length) spalte.append(el('p', 'spalte__leer', '—')); else for (const a of eigene) spalte.append(karte(a)); brett.append(spalte); } } async function laden() { try { const antwort = await hole('/workspace/api/aufgaben'); if (!antwort.ok) { melde('Aufgaben konnten nicht geladen werden.'); return; } zeichne((await antwort.json()).aufgaben); } catch { /* bereits umgeleitet */ } } /* ---------- Neue Aufgabe -------------------------------------------- */ function formularUmschalten(auf) { $('neu').hidden = !auf; $('neu-oeffnen').hidden = auf; if (auf) $('f-titel').focus(); } $('neu-oeffnen').addEventListener('click', () => formularUmschalten(true)); $('neu-abbrechen').addEventListener('click', () => { $('neu').reset(); $('neu-fehler').textContent = ''; formularUmschalten(false); }); $('neu').addEventListener('submit', async (e) => { e.preventDefault(); const knopfEl = $('neu-speichern'); $('neu-fehler').textContent = ''; const daten = { titel: $('f-titel').value, beschreibung: $('f-text').value, prioritaet: $('f-prio').value, frist: $('f-frist').value, }; if (LEITUNG.has(ich.rolle)) { daten.creator_id = $('f-creator').value || null; daten.verantwortlich_id = $('f-verant').value || null; } knopfEl.disabled = true; try { const antwort = await hole('/workspace/api/aufgaben', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(daten), }); if (!antwort.ok) { const d = await antwort.json().catch(() => ({})); $('neu-fehler').textContent = d.fehler || 'Anlegen hat nicht geklappt.'; return; } $('neu').reset(); formularUmschalten(false); await laden(); } catch { /* bereits umgeleitet */ } finally { knopfEl.disabled = false; } }); /* ---------- Bearbeiten ------------------------------------------------ */ const dialog = $('bearbeiten-dialog'); let bearbeitet = null; function dialogOeffnen(a) { bearbeitet = a; $('b-titel').value = a.titel; $('b-text').value = a.beschreibung || ''; $('b-prio').value = a.prioritaet; $('b-frist').value = a.frist || ''; $('b-fehler').textContent = ''; if (LEITUNG.has(ich.rolle)) { $('b-creator').value = a.creator_id ? String(a.creator_id) : ''; $('b-verant').value = a.verantwortlich_id ? String(a.verantwortlich_id) : ''; $('b-loeschen').hidden = false; } dialog.showModal(); $('b-titel').focus(); } $('b-abbrechen').addEventListener('click', () => dialog.close()); $('b-speichern').addEventListener('click', async () => { const daten = { titel: $('b-titel').value, beschreibung: $('b-text').value, prioritaet: $('b-prio').value, frist: $('b-frist').value, }; if (LEITUNG.has(ich.rolle)) { daten.creator_id = $('b-creator').value || null; daten.verantwortlich_id = $('b-verant').value || null; } $('b-speichern').disabled = true; try { const a = await hole(`/workspace/api/aufgaben/${bearbeitet.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(daten), }); if (!a.ok) { $('b-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Speichern ging nicht.'; return; } dialog.close(); await laden(); } catch { /* umgeleitet */ } finally { $('b-speichern').disabled = false; } }); $('b-loeschen').addEventListener('click', async () => { if (!confirm(`Aufgabe wirklich loeschen? "${bearbeitet.titel}" Das laesst sich nicht rueckgaengig machen. Erledigte Aufgaben sollten normalerweise stehen bleiben.`)) return; try { const a = await hole(`/workspace/api/aufgaben/${bearbeitet.id}`, { method: 'DELETE' }); if (!a.ok) { $('b-fehler').textContent = 'Loeschen ging nicht.'; return; } dialog.close(); await laden(); } catch { /* umgeleitet */ } }); /* ---------- Start ----------------------------------------------------- */ (async () => { try { const antwort = await hole('/workspace/api/ich'); if (!antwort.ok) { location.assign('/workspace/'); return; } ich = await antwort.json(); } catch { return; } $('wer').textContent = `${ich.name} · ${ich.rolle_name || ich.rolle}`; if (LEITUNG.has(ich.rolle)) { $('feld-creator').hidden = false; $('feld-verant').hidden = false; $('b-feld-creator').hidden = false; $('b-feld-verant').hidden = false; try { const { personen } = await (await hole('/workspace/api/personen')).json(); for (const p of personen) { for (const ziel of ['f-creator', 'f-verant', 'b-creator', 'b-verant']) { const o = el('option', null, `${p.name} (${p.rolle})`); o.value = String(p.id); $(ziel).append(o); } } } catch { /* Zuordnung bleibt dann leer -- kein Grund abzubrechen */ } } await laden(); })(); })();