/* =================================================================== 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. */ /* Die Leitungsliste steht in bereiche.js -- eine Antwort statt neun. Sie stand hier frueher selbst, ohne 'spicy', und genau das war der Fehler, den Filipe am 07.09.2026 gefunden hat. */ const LEITUNG = window.Bereiche.LEITUNG; /* `leer` ist der Satz, der in einer leeren Spalte steht. Vorher stand dort in allen vier Spalten ein Gedankenstrich -- vier graue Striche nebeneinander sehen aus, als sei die Seite kaputt, obwohl gar nichts kaputt ist. Jeder Satz sagt jetzt, was das Leersein BEDEUTET: eine leere Review-Spalte ist etwas anderes als eine leere Erledigt-Spalte. `unter` erklärt die Spalte auch dann, wenn sie voll ist. */ const SPALTEN = [ { status: 'offen', name: 'Offen', unter: 'Angenommen, noch nicht angefangen', leer: 'Nichts liegt herum.' }, { status: 'arbeit', name: 'In Arbeit', unter: 'Läuft gerade', leer: 'Gerade läuft nichts.' }, { status: 'review', name: 'Review', unter: 'Wartet auf Freigabe', leer: 'Nichts wartet auf Freigabe.' }, { status: 'erledigt', name: 'Erledigt', unter: 'Fertig und abgehakt', leer: 'Hier landet, was fertig ist.' }, ]; const WEITER = { offen: 'arbeit', arbeit: 'review', review: 'erledigt' }; const ZURUECK = { arbeit: 'offen', review: 'arbeit', erledigt: 'review' }; /* Wer darf abbrechen (05.09.2026): *"nur ich die manager und scouts sollen auch die aufgaben abbrechen können."* Ein Creator nicht. Dieselbe Menge steht im Server (DARF_ABBRECHEN in workspace-aufgaben.js) und DORT sitzt die Sperre. Hier geht es nur darum, niemandem einen Knopf hinzustellen, der dann 403 sagt. */ /* Leitung plus Scouts. Aus der einen Rollenquelle abgeleitet statt abgeschrieben -- die abgeschriebene Fassung hatte 'spicy' vergessen, und ein fehlender Knopf faellt niemandem auf. */ const DARF_ABBRECHEN = new Set([...window.Bereiche.LEITUNG, 'scout']); const STATUS_NAME = { offen: 'Offen', arbeit: 'In Arbeit', review: 'Review', erledigt: 'Erledigt', }; const $ = (id) => document.getElementById(id); /* ORTSZEIT, nicht UTC -- sonst gilt nachts eine heute faellige Aufgabe noch nicht als faellig, und "Heute faellig" zeigt den Vortag. */ const heute = window.heuteLokal(); let ich = null; /* Nur beim ERSTEN Laden springen. Sonst reisst es die Seite nach jeder Statusaenderung erneut an dieselbe Stelle. */ let gesprungen = false; /* 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; /* DIE NUMMER AN DER KARTE. Ohne sie kann kein Verweis auf eine EINZELNE Aufgabe zeigen -- und drei davon gibt es im Haus. */ k.dataset.id = String(a.id); 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'); /* WICHTIG STEHT DA, statt nur eine Kante einzufaerben. Die Prioritaet gab es als 3 px breiten Rand links -- direkt neben der farbigen Kante der Spalte, und damit praktisch unsichtbar. Als Wort steht sie da, wo man sie liest. NUR bei "hoch". Eine Marke an jeder Karte waere keine Marke mehr: Was immer da steht, sieht man nach zwei Tagen nicht mehr. */ if (a.prioritaet === 'hoch' && a.status !== 'erledigt') { zeile.append(el('span', 'karte__wichtig', 'wichtig')); } /* ---- WIE LANGE GEHT DAS SCHON SO? (15.09.2026, Stufe 9) -------- "Ueberfaellig" war ein Schalter: an oder aus. Eine Karte, deren Frist gestern lief, sah aus wie eine, die seit drei Wochen liegt. Stehen auf einem Brett zwanzig Karten "ueberfaellig", sagt keine davon mehr etwas -- man ueberliest sie alle, auch die, die seit einem Monat blockiert. DAS WORT KOMMT VOM SERVER. Eine zweite Rechnung hier waere die, die irgendwann anders antwortet als die Startseite. */ if (a.eskalation) { const e = el('span', 'karte__eskal', a.eskalation.wort); e.dataset.stufe = a.eskalation.stufe; e.title = a.eskalation.grund === 'frist' ? 'Die Frist ist vorbei. Die Zahl sagt, seit wann.' : 'Ohne Frist – seit so vielen Tagen hat niemand die Karte angefasst.'; zeile.append(e); } if (a.frist) { /* Das Wort "ueberfaellig" steht jetzt oben in der Stufe. Hier bleibt nur noch das Datum -- zweimal dasselbe nebeneinander waere Laerm. */ const faellig = !!a.eskalation && a.eskalation.grund === 'frist'; const f = el('span', 'karte__frist' + (faellig ? ' karte__frist--faellig' : ''), 'bis ' + datumHuebsch(a.frist)); zeile.append(f); } /* Die Kategorie als Wort in derselben Zeile wie Frist und Person. Kein eigener Streifen und keine eigene Farbe: Sie ist eine Einordnung, keine Warnung -- und eine Karte, auf der alles hervorsticht, hebt nichts hervor. Der Name kommt aus der Liste des Servers; steht die Aufgabe in einer Kategorie, die es nicht mehr gibt, wird nichts angezeigt statt eines Schluessels. KEINE EIGENE CSS-KLASSE. Sie muesste in sieben gleichlautenden Kopien der Modulliste gepflegt werden (pruef-css-klassen wacht darueber, dass die sieben Zeichen fuer Zeichen gleich sind) -- viel Aufwand und sieben Gelegenheiten fuer einen Unterschied, fuer eine Zeile Text. Dieselbe schlichte Form wie "· Bereich X" daneben tut es. */ /* DER KANAL STEHT VOR DER KATEGORIE. Er beantwortet die groebere Frage -- WO ueberhaupt -- und die muss man zuerst wissen, um die feinere zu verstehen. */ if (a.kanal) { const kName = kanaele.find((k) => k.wert === a.kanal)?.name; if (kName) { const z = el('span', 'aufgabe__kanal', kName); z.dataset.kanal = a.kanal; zeile.append(z); } } if (a.kategorie) { const name = kategorien.find((k) => k.wert === a.kategorie)?.name; if (name) zeile.append(el('span', null, '· ' + name)); } 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])); /* ABBRECHEN — in JEDEM Status (05.09.2026), auch im erledigten: Auch etwas Abgehaktes kann sich als hinfällig herausstellen. Nur DogFather, Manager und Scouts. Ein Creator sieht den Knopf gar nicht erst -- die eigentliche Sperre sitzt aber im Server, hier geht es nur darum, niemandem etwas anzubieten, das dann abgelehnt wird. */ if (DARF_ABBRECHEN.has(ich.rolle)) { const ab = el('button', 'schritt schritt--ab', 'abbrechen'); ab.type = 'button'; ab.setAttribute('aria-label', `„${a.titel}" abbrechen`); ab.addEventListener('click', () => abbruchOeffnen(a)); schritte.append(ab); } 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; } /* ---------- Abbrechen ---------------------------------------------------- Wunsch 05.09.2026: *"ich will dass man in jedem status die aufgaben auch abbrechen kann."* MIT PFLICHT-GRUND. Ohne ihn weiss in vier Wochen niemand mehr, warum etwas wegfiel -- und dann ist ein Abbruch von einem Loeschen nicht mehr zu unterscheiden. Der Grund steht spaeter an der Aufgabe, zusammen mit dem Namen dessen, der abgebrochen hat. Ein und kein confirm(): confirm blockiert die ganze Seite, nimmt kein Textfeld auf und sieht auf jedem Rechner anders aus. */ function abbruchOeffnen(a) { const dlg = $('abbruch-dialog'); if (!dlg) return; dlg.dataset.id = String(a.id); $('abbruch-titel').textContent = `„${a.titel}" abbrechen?`; /* Sagt, was gleich passiert -- und was NICHT passiert. Die Sorge bei so einem Knopf ist immer, ob etwas verloren geht. */ $('abbruch-was').textContent = `Die Aufgabe steht danach unter „Abgebrochen" ` + `statt in der Spalte ${STATUS_NAME[a.status] || a.status}. ` + `Sie wird nicht gelöscht und lässt sich jederzeit wieder aufnehmen.`; $('abbruch-grund').value = ''; $('abbruch-fehler').textContent = ''; dlg.showModal(); $('abbruch-grund').focus(); } async function abbruchSenden(e) { e.preventDefault(); const dlg = $('abbruch-dialog'); const id = Number(dlg.dataset.id); const grund = $('abbruch-grund').value.trim(); if (grund.length < 3) { $('abbruch-fehler').textContent = 'Bitte kurz sagen, warum.'; $('abbruch-grund').focus(); return; } const knopf = $('abbruch-ok'); knopf.disabled = true; try { const antwort = await hole(`/workspace/api/aufgaben/${id}/abbrechen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grund }), }); if (!antwort.ok) { const d = await antwort.json().catch(() => ({})); $('abbruch-fehler').textContent = d.fehler || 'Hat nicht geklappt.'; return; } dlg.close(); await laden(); } catch { /* umgeleitet */ } finally { knopf.disabled = false; } } async function wiederAufnehmen(a, b) { b.disabled = true; melde(''); try { const antwort = await hole(`/workspace/api/aufgaben/${a.id}/wiederaufnehmen`, { method: 'POST' }); if (!antwort.ok) { const d = await antwort.json().catch(() => ({})); melde(d.fehler || 'Hat nicht geklappt.'); b.disabled = false; return; } await laden(); } catch { b.disabled = false; } } /** Der Bereich UNTER dem Brett -- zugeklappt, mit Anzahl im Titel. * * Bewusst keine fuenfte Spalte: Am Handy werden dadurch alle fuenf * so schmal, dass keine mehr lesbar ist. Und abgebrochene Aufgaben * sind nichts, was man taeglich sieht -- aber sie duerfen auch nicht * verschwinden, sonst waere es Loeschen mit Zwischenschritt. */ function abgebrocheneZeigen(alle) { const kasten = $('abgebrochen'); if (!kasten) return; const eigene = alle.filter((a) => a.status === 'abgebrochen'); if (!eigene.length) { kasten.hidden = true; return; } kasten.hidden = false; $('abgebrochen-zahl').textContent = String(eigene.length); const liste = $('abgebrochen-liste'); liste.textContent = ''; for (const a of eigene) { const k = el('article', 'abbruch-karte'); const kopf = el('div', 'abbruch-karte__kopf'); kopf.append(el('h3', 'abbruch-karte__titel', a.titel)); /* Wo es stand, als es abgebrochen wurde. "Im Review abgebrochen" ist eine andere Aussage als "nie angefangen". */ if (a.status_vorher) { kopf.append(el('span', 'abbruch-karte__vorher', STATUS_NAME[a.status_vorher] || a.status_vorher)); } k.append(kopf); if (a.abbruch_grund) k.append(el('p', 'abbruch-karte__grund', a.abbruch_grund)); const wer = []; if (a.abbruch_von_name) wer.push(a.abbruch_von_name); if (a.abgebrochen_am) wer.push(datumHuebsch(a.abgebrochen_am.slice(0, 10))); if (wer.length) k.append(el('p', 'abbruch-karte__wer', wer.join(' · '))); if (DARF_ABBRECHEN.has(ich.rolle)) { const zurueck = el('button', 'schritt', 'wieder aufnehmen ↩'); zurueck.type = 'button'; zurueck.setAttribute('aria-label', `„${a.titel}" wieder aufnehmen`); zurueck.addEventListener('click', () => wiederAufnehmen(a, zurueck)); k.append(zurueck); } liste.append(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; } /* ---------- Die Leiste ueber dem Brett ----------------------------------- Wunsch vom 01.09.2026: "verbessere die Aufgabenseite auch bei Manager, Scout und Creator. Die Scouts und Manager sollen vielleicht ein bisschen mehr Optionen haben, wie ihre zugeteilten Creator. Aber auch die Creator sollen es besser und detaillierter sehen." Drei Dinge, und die Reihenfolge ist die Aussage: 1. WAS BRENNT. Ein Satz beim Reinkommen, ob etwas ueberfaellig ist. Ein Brett mit vier Spalten beantwortet das nicht -- man muss vier Spalten durchsehen, um zu wissen, ob man ruhig anfangen kann. 2. AUSSCHNITTE. "Nur meine", "Heute faellig", "Ueberfaellig" -- die Fragen, die man wirklich hat. Sie filtern das Brett, statt eine zweite Liste aufzumachen: Vier Spalten nebeneinander sind der Wert dieser Seite, den nimmt man nicht weg. 3. DIE CREATOR, nur fuer Betreuer. Wer drei betreut, will sehen, was bei einem davon liegt -- ohne die anderen zwei dazwischen. Ein Creator bekommt diese Reihe nicht: Er hat nur sich, und ein Filter mit einem einzigen Eintrag ist ein Knopf, der nichts tut. Alles hier ist ANSICHT. Wer was sehen darf, hat der Server entschieden, bevor eine einzige Aufgabe hier ankommt. */ let ausschnitt = ''; /* '', 'meine', 'faellig', 'heute' */ let nurCreator = 0; /* 0 = alle */ let alleAufgaben = []; /* Abgebrochenes zählt NICHT als offen (05.09.2026). Ohne diese Ergänzung stünde eine abgebrochene Aufgabe weiter in "3 Aufgaben stehen an" und in "Überfällig" -- und würde als etwas gemahnt, das ausdrücklich niemand mehr tun soll. */ const istOffen = (a) => a.status !== 'erledigt' && a.status !== 'abgebrochen'; const istFaellig = (a) => istOffen(a) && a.frist && a.frist < heute; const istHeute = (a) => istOffen(a) && a.frist === heute; const istMeine = (a) => a.verantwortlich_id === ich.id; function passt(a) { if (nurCreator && a.creator_id !== nurCreator) return false; if (ausschnitt === 'meine') return istMeine(a); if (ausschnitt === 'faellig') return istFaellig(a); if (ausschnitt === 'heute') return istHeute(a); return true; } function lageZeigen(alle) { const ziel = $('lage'); if (!ziel) return; const faellig = alle.filter(istFaellig).length; const heuteN = alle.filter(istHeute).length; const offen = alle.filter(istOffen).length; const meine = alle.filter((a) => istOffen(a) && istMeine(a)).length; ziel.textContent = ''; let satz, stufe; if (faellig) { satz = `${faellig} ${faellig === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} überfällig – das zuerst.`; stufe = 'warnung'; } else if (heuteN) { satz = `${heuteN} ${heuteN === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} heute fällig.`; stufe = 'offen'; } else if (offen) { satz = `${offen} ${offen === 1 ? 'Aufgabe steht' : 'Aufgaben stehen'} an, nichts davon brennt.`; stufe = 'offen'; } else { satz = 'Nichts offen – alles abgearbeitet.'; stufe = 'ruhig'; } ziel.dataset.stufe = stufe; ziel.append(el('span', 'lage__satz', satz)); /* Die eigene Zahl steht daneben, nicht im Satz: Auf einem Brett, das auch fremde Aufgaben zeigt, ist "wie viel davon ist meins" die zweite Frage -- aber eben die zweite. */ if (ich.rolle !== 'creator' && meine) { ziel.append(el('span', 'lage__meins', `${meine} davon bei dir`)); } ziel.hidden = false; } function ausschnitteBauen(alle) { const ziel = $('ausschnitte'); if (!ziel) return; ziel.textContent = ''; const knopf = (wert, name, anzahl) => { const b = el('button', 'schnitt'); b.type = 'button'; b.dataset.wert = wert; b.setAttribute('aria-pressed', String(ausschnitt === wert)); b.append(el('span', null, name)); /* Die Zahl steht am Knopf. Ein Filter, der sich erst nach dem Klick als leer herausstellt, kostet zweimal Aufmerksamkeit. */ b.append(el('span', 'schnitt__zahl', String(anzahl))); if (!anzahl && wert) b.disabled = true; b.addEventListener('click', () => { ausschnitt = ausschnitt === wert ? '' : wert; neuZeichnen(); }); return b; }; ziel.append(knopf('', 'Alle', alle.length)); if (ich.rolle !== 'creator') { ziel.append(knopf('meine', 'Nur meine', alle.filter((a) => istOffen(a) && istMeine(a)).length)); } ziel.append(knopf('heute', 'Heute fällig', alle.filter(istHeute).length)); const f = knopf('faellig', 'Überfällig', alle.filter(istFaellig).length); f.dataset.warn = 'ja'; ziel.append(f); ziel.hidden = false; } function creatorReiheBauen(alle) { const ziel = $('creator-reihe'); if (!ziel) return; ziel.textContent = ''; if (ich.rolle === 'creator') { ziel.hidden = true; return; } /* Die Liste kommt aus den Aufgaben selbst, nicht aus einer Personenabfrage: Dann stehen dort genau die Creator, die man ohnehin sehen darf -- und niemals einer mehr. */ const nach = new Map(); for (const a of alle) { if (!a.creator_id || !a.creator_name) continue; const w = nach.get(a.creator_id) || { name: a.creator_name, offen: 0, faellig: 0 }; if (istOffen(a)) w.offen++; if (istFaellig(a)) w.faellig++; nach.set(a.creator_id, w); } /* Unter zwei Creatorn ist die Reihe eine Zeile ohne Auswahl. */ if (nach.size < 2) { ziel.hidden = true; return; } /* "Deine Creator" stimmt fuer Scout und Manager -- bei DogFather waeren es ALLE, und "deine" waere dort eine falsche Auskunft. */ ziel.append(el('span', 'creator-reihe__schild', ich.rolle === 'admin' ? 'Alle Creator' : 'Deine Creator')); const knopf = (id, name, offen, faellig) => { const b = el('button', 'creator-knopf'); b.type = 'button'; b.setAttribute('aria-pressed', String(nurCreator === id)); b.append(el('span', 'creator-knopf__name', name)); b.append(el('span', 'creator-knopf__zahl', String(offen))); if (faellig) { const m = el('span', 'creator-knopf__warn', String(faellig)); m.title = `${faellig} überfällig`; b.append(m); } b.addEventListener('click', () => { nurCreator = nurCreator === id ? 0 : id; neuZeichnen(); }); return b; }; ziel.append(knopf(0, 'Alle', alle.filter(istOffen).length, alle.filter(istFaellig).length)); for (const [id, w] of [...nach.entries()].sort((a, b) => b[1].offen - a[1].offen)) { ziel.append(knopf(id, w.name, w.offen, w.faellig)); } ziel.hidden = false; } function neuZeichnen() { ausschnitteBauen(alleAufgaben); creatorReiheBauen(alleAufgaben); zeichne(alleAufgaben.filter(passt)); /* Der Bereich folgt dem Creator-Filter mit -- wer sich eine Person herausgreift, will auch nur deren Abgebrochenes sehen. Die Ausschnitte (heute/überfällig) gelten dagegen nicht: Sie fragen nach Fristen, und eine abgebrochene Aufgabe hat keine mehr. */ abgebrocheneZeigen(nurCreator ? alleAufgaben.filter((a) => a.creator_id === nurCreator) : alleAufgaben); } 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; /* Die Karte merkt sich, wozu sie gehoert -- daraus findet der Sprung vom Bericht die richtigen wieder, ohne die Liste ein zweites Mal durchzugehen. */ 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); /* Was diese Spalte bedeutet -- steht immer da, nicht nur wenn sie leer ist. "Review" allein sagt einem Neuen nichts. */ spalte.append(el('p', 'spalte__unter', s.unter)); if (!eigene.length) spalte.append(el('p', 'spalte__leer', s.leer)); else for (const a of eigene) { const k = karte(a); k.dataset.status = a.status; if (a.status !== 'erledigt' && a.frist && a.frist < heute) k.dataset.faellig = 'ja'; /* "Heute faellig" ist ein eigener Zustand, kein Sonderfall von "ueberfaellig": Die Frist ist HEUTE und damit noch nicht vorbei. Ohne dieses Merkmal koennte der Sprung von der Startseite ("Heute faellig") nichts finden -- er zeigt dann eine leere Auswahl, und man glaubt, man sei falsch abgebogen. */ if (a.status !== 'erledigt' && a.frist === heute) k.dataset.heute = 'ja'; spalte.append(k); } brett.append(spalte); } } /* ---------- Sprung vom Bericht ------------------------------------------- "wenn man auf die Kisten drueckt, sofort zu diesem Punkt weitergeschickt werden" (01.09.2026). Hier wird NICHT gefiltert, sondern hervorgehoben. Das Brett lebt davon, dass man alle vier Spalten nebeneinander sieht -- wer nur die ueberfaelligen zeigt, nimmt genau den Ueberblick weg, wegen dem man hergekommen ist. */ /* ZWEI SCHLUESSEL KAMEN AM 09.09.2026 DAZU (screen1). Filipe, mit Bild der sieben Zahlenkarten auf der Startseite: "wenn ich auf die druecke soll ich auf zu denen punkten gebracht werden." Fuenf der sieben konnten das schon (offen, arbeit, review, erledigt, ueberfaellig) -- nur wusste es niemand, weil die Karten keine Links waren. "Heute faellig" und "Abgebrochen" fehlten ganz. */ const ZEIG_TEXT = { offen: 'Aufgaben, die offen sind', arbeit: 'Aufgaben, die gerade laufen', review: 'Aufgaben, die auf Freigabe warten', erledigt: 'Aufgaben, die erledigt sind', ueberfaellig: 'Aufgaben, deren Frist vorbei ist', heute: 'Aufgaben, die heute fällig sind', abgebrochen: 'Aufgaben, die abgebrochen wurden', }; function sprungZeigen() { /* EINE EINZELNE AUFGABE, ueber `#a` in der Adresse. Sie steht VOR der Auswertung von `?zeigen=`, weil sie genauer ist: Wer eine bestimmte Aufgabe meint, meint nicht ihre Spalte. Die Nummer kommt aus dem Anker und nicht aus `?zeigen=`, weil das Brett von drei Stellen aus so verlinkt wird -- und die Schreibweise war schon da, nur gelesen hat sie niemand. */ const einzeln = /^#a(\d+)$/.exec(location.hash || ""); if (einzeln) { const k = document.querySelector(`.karte[data-id="${einzeln[1]}"]`); window.zielMarkieren?.(k ? [k] : [], 'Diese eine Aufgabe'); return; } const was = window.zielSchluessel?.(); if (!was || !ZEIG_TEXT[was]) return; /* ABGEBROCHENE STEHEN NICHT IM BRETT, sondern in einem eigenen Kasten weiter unten -- und der ist zugeklappt, solange niemand ihn aufmacht. Ein Sprung dorthin muss ihn also erst OEFFNEN, sonst landet man an einer Stelle, an der nichts zu sehen ist. Genau die Sorte Sprung, die einen glauben laesst, der Knopf sei kaputt. */ if (was === 'abgebrochen') { /* `#abgebrochen` IST das
, nicht sein Behaelter. Im ersten Anlauf stand hier `kasten.querySelector('details')` -- das haette null geliefert und den Kasten zugeklappt gelassen. Nachgesehen im Markup statt vermutet. */ const kasten = $('abgebrochen'); if (kasten) kasten.open = true; window.zielMarkieren?.(document.querySelectorAll('.abbruch-karte'), ZEIG_TEXT[was]); return; } const treffer = was === 'ueberfaellig' ? document.querySelectorAll('.karte[data-faellig="ja"]') : was === 'heute' ? document.querySelectorAll('.karte[data-heute="ja"]') : document.querySelectorAll(`.karte[data-status="${was}"]`); window.zielMarkieren?.(treffer, ZEIG_TEXT[was]); } /* ===================================================================== DIE KATEGORIE EINER AUFGABE (10.09.2026, Kapitel 6.1) Filipes Entscheidung: nur bei den Modis. Fuer Creator, Scouts und Manager bleibt das Formular unveraendert -- sie bekommen das Feld nicht ausgeblendet, sondern gar nicht: Der Server schickt ihnen keine Kategorien, und ohne Kategorien wird nichts gebaut. WARUM DIE NAMEN NICHT HIER STEHEN: Diese Datei bekommt jeder ausgeliefert, der die Seite oeffnet. Eine Liste, die im Browser jedes Menschen liegt und nur bei einem einzigen benutzt wird, wirft die Frage auf, fuer wen sie da ist. Genau die soll niemand stellen. ===================================================================== */ let kategorien = []; let kanaele = []; /* FUER WESSEN AUFGABEN die Kategorie gilt. Kommt fertig vom Server: null -> fuer alle eigenen (das Feld steht immer da) [Nummern] -> nur wenn eine dieser Personen gewaehlt ist [] -> nie ALS NUMMERN UND NICHT ALS ROLLENNAME, und das ist kein Zufall: Beim ersten Anlauf stand hier ein Vergleich auf einen Rollennamen -- in einer Datei, die JEDER bekommt, der die Seite oeffnet. Aus Nummern laesst sich nichts schliessen; wer keine bekommt, sieht eine leere Liste, und eine leere Liste sagt nichts. */ let kategorieFuer = []; function kategorienEinsetzen() { if (!kategorien.length) return; for (const ziel of ['f-kategorie', 'b-kategorie']) { const feld = $(ziel); if (!feld || feld.dataset.fertig) continue; feld.dataset.fertig = 'ja'; for (const k of kategorien) { const o = el('option', null, k.name); o.value = k.wert; feld.append(o); } } } /* Braucht diese Aufgabe eine Kategorie? `null` heisst "immer" -- dann arbeitet die angemeldete Person ausschliesslich an eigenen Aufgaben, und dort gilt sie unabhaengig davon, wen sie eintraegt. Sonst entscheidet die gewaehlte Person. Wer eine leere Liste bekommt, sieht das Feld nie. */ /* ---- DIE KANAELE (17.09.2026) -------------------------------------- Filipe: "ich hab ja auch 2 neben account, hasidog und dogfather clips." Ohne diesen Wert ist "schneide drei Ausschnitte" bei drei Accounts keine Aufgabe, sondern eine Frage. EINFACHER ALS BEI DEN KATEGORIEN, und das ist Absicht: Die Kategorie haengt daran, FUER WEN die Aufgabe ist (`kategorie_fuer`); ein Kanal gilt fuer jede Aufgabe, die jemand aus dem Haus anfasst. Zwei verschiedene Sichtbarkeitsregeln fuer zwei Felder, die nebeneinander stehen, waeren eine Gelegenheit, die falsche zu ziehen. Hier gibt es genau eine Frage: Darf diese Person ueberhaupt Kanaele benutzen? */ function kanaeleEinsetzen() { if (!kanaele.length) return; for (const ziel of ['f-kanal', 'b-kanal']) { const feld = $(ziel); if (!feld) continue; const vorher = feld.value; /* "Für alle" ist kein Platzhalter, sondern eine Antwort -- vieles ist nicht kanalgebunden (eine Absprache, ein Zugang, eine Auswertung). Deshalb steht dort ein Wort und kein Strich. */ feld.textContent = ''; feld.append(el('option', null, 'Für alle')); feld.firstChild.value = ''; for (const k of kanaele) { const o = el('option', null, k.name); o.value = k.wert; if (k.text) o.title = k.text; feld.append(o); } feld.value = vorher; } } function kanalFeldZeigen(praefix) { const huelle = $(praefix === 'f' ? 'feld-kanal' : 'b-feld-kanal'); if (!huelle) return; huelle.hidden = !kanaele.length; if (!kanaele.length && $(`${praefix}-kanal`)) $(`${praefix}-kanal`).value = ''; } function kategorieNoetig(creatorId, verantId) { if (kategorieFuer === null) return true; if (!Array.isArray(kategorieFuer) || !kategorieFuer.length) return false; return kategorieFuer.includes(Number(creatorId)) || kategorieFuer.includes(Number(verantId)); } function kategorieFeldZeigen(praefix) { const huelle = $(praefix === 'f' ? 'feld-kategorie' : 'b-feld-kategorie'); if (!huelle) return; const zeigen = kategorien.length && kategorieNoetig($(`${praefix}-creator`)?.value, $(`${praefix}-verant`)?.value); huelle.hidden = !zeigen; /* Verborgen heisst auch: nichts mitschicken. Ein Wert, der in einem unsichtbaren Feld stehenbleibt, wandert sonst beim naechsten Speichern mit -- und niemand sieht, woher er kommt. */ if (!zeigen && $(`${praefix}-kategorie`)) $(`${praefix}-kategorie`).value = ''; } /* Die Aufwandsstufen kommen mit der Aufgabenliste. Hier weitergereicht, damit die Auswahl im Dialog dieselbe Liste zeigt, die der Server annimmt. */ function aufwandUebernehmen(daten) { window.aufwandStufenSetzen?.(daten?.aufwand); } async function laden() { try { const antwort = await hole('/workspace/api/aufgaben'); if (!antwort.ok) { melde('Aufgaben konnten nicht geladen werden.'); return; } const daten = await antwort.json(); alleAufgaben = daten.aufgaben || []; aufwandUebernehmen(daten); kategorien = daten.kategorien || []; kanaele = daten.kanaele || []; /* `null` muss `null` BLEIBEN -- ein `|| []` daraus zu machen waere der stille Fehler: Aus "gilt immer" wuerde "gilt nie", und das Feld verschwaende fuer genau die, die es brauchen. */ kategorieFuer = daten.kategorie_fuer === undefined ? [] : daten.kategorie_fuer; kategorienEinsetzen(); kategorieFeldZeigen('f'); kanaeleEinsetzen(); kanalFeldZeigen('f'); kanalFeldZeigen('b'); lageZeigen(alleAufgaben); neuZeichnen(); /* Erst NACH dem Zeichnen -- vorher gibt es die Karten nicht, und der Sprung ginge ins Leere, ohne dass jemand etwas merkt. Nur beim ersten Laden: Sonst springt die Seite nach jeder Statusaenderung erneut weg. */ if (!gesprungen) { gesprungen = true; sprungZeigen(); } } 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; daten.creator_extern = $('f-creator').dataset.extern || null; daten.verantwortlich_extern = $('f-verant').dataset.extern || null; } if (!$('feld-kategorie')?.hidden) daten.kategorie = $('f-kategorie').value || ''; if (!$('feld-kanal')?.hidden) daten.kanal = $('f-kanal').value || ''; 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; } kategorieFeldZeigen('b'); if ($('b-kategorie')) $('b-kategorie').value = a.kategorie || ''; if ($('b-kanal')) $('b-kanal').value = a.kanal || ''; if ($('b-aufwand')) $('b-aufwand').value = a.aufwand || ''; window.anhaengeLaden?.(a.id); dialog.showModal(); $('b-titel').focus(); } $('b-abbrechen').addEventListener('click', () => dialog.close()); /* Der Abbruch-Dialog. Am submit des Formulars und nicht am Knopf: So löst auch Enter im Textfeld aus, und Esc schließt von selbst (beides bringt mit, wenn man es lässt). */ $('abbruch-form').addEventListener('submit', abbruchSenden); $('abbruch-zurueck').addEventListener('click', () => $('abbruch-dialog').close()); $('b-speichern').addEventListener('click', async () => { const daten = { aufwand: $('b-aufwand') ? $('b-aufwand').value : undefined, 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; daten.creator_extern = $('b-creator').dataset.extern || null; daten.verantwortlich_extern = $('b-verant').dataset.extern || null; } if (!$('b-feld-kategorie')?.hidden) daten.kategorie = $('b-kategorie').value || ''; if (!$('b-feld-kanal')?.hidden) daten.kanal = $('b-kanal').value || ''; $('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; } window.werZeigen?.(ich); 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(); /* AUCH ALS DATEN BEHALTEN, nicht nur als