Files
dogfather-universe/workspace/assets/js/aufgaben.js
T
DogFatherGitandClaude Opus 5 2d5972f45f Wie lange geht das schon so -- Eskalation statt eines Schalters
Stufe 9 aus dem Plan: "Eskalationsstufe sichtbar". Damit ist Stufe 9
bis auf einen Punkt durch.

DER MANGEL, GEMESSEN

"Ueberfaellig" war ein Schalter: an oder aus. Eine Aufgabe, deren Frist
gestern lief, sah genauso aus wie eine, die seit drei Wochen liegt --
dasselbe Wort, dieselbe Farbe, derselbe Platz.

Damit verliert das Wort seine Bedeutung. Stehen auf einem Brett zwanzig
Karten "ueberfaellig", sagt keine davon mehr etwas; man ueberliest sie
alle, auch die, die seit einem Monat blockiert.

ZWEI VERSCHIEDENE FRAGEN, DIE NICHT VERMISCHT WERDEN DUERFEN

  FRIST UEBERSCHRITTEN   Es war etwas versprochen, der Termin ist
                         vorbei. Eine Tatsache.
                         faellig (ab 1 Tag) -> liegt (ab 3) -> haengt (ab 8)

  NICHTS PASSIERT        Keine Frist, aber seit 14 Tagen hat niemand
                         die Karte angefasst. Eine Beobachtung.

Die erste ist haerter und steht vorn. Eine Aufgabe ohne Frist ist nicht
"zu spaet" -- sie ist nur still. Beides in einen Topf zu werfen hiesse,
jemandem einen gebrochenen Termin vorzuwerfen, den er nie zugesagt hat.
Deshalb ist "still" auch farblos gezeichnet, gestrichelt statt gefuellt.

DAS IST KEINE MAHNUNG

Dieselbe Haltung wie bei der Standzeit im Talente-Trichter: Die Zahl ist
eine Auskunft ueber UNS, kein Vorwurf an eine Person. Deshalb steht
nirgends ein Name, und die hoechste Stufe heisst "haengt", nicht
"versaeumt".

DIE SCHWELLEN SIND GESETZT, NICHT GEMESSEN -- und das steht so im Code:
unter 3 Tagen reicht ein Wochenende; ab 3 ist es ein Zustand; ab 8 (ueber
eine Woche) kommt es ohne Hilfe auch naechste Woche nicht dazu. Aendert
sich eine Zahl, aendert sie sich an einer Stelle, und die Oberflaeche
zieht mit -- sie bekommt das WORT vom Server.

AM SERVER GERECHNET. Startseite, Brett und Uebersicht lesen dieselbe
Liste. Drei Stellen, die dieselbe Frage selbst beantworten, geben
irgendwann drei Antworten -- genau der Fehler, der im Projekt schon bei
der Rollenreihenfolge und beim Wort "Agentur" passiert ist.

DIE FARBE IST NICHT DIE AUSKUNFT: In jedem Kaestchen steht ein Wort
("liegt seit 5 Tagen"), nicht nur ein roterer Punkt.

PRUEFUNGEN: pruef-eskalation neu mit 40, davon 6 im Browser. Gemessen
wird an den GRENZEN, nicht in der Mitte -- jede Schwelle dreimal: einen
Tag davor, genau darauf, einen Tag danach. Die wichtigste ist die erste:
am Tag der Frist ist nichts ueberfaellig. Wer bis zum 15. Zeit hat, ist
am 15. puenktlich; eine Eskalation, die einen Tag zu frueh anschlaegt,
verliert genau das Vertrauen, das sie braucht.

Die Pruefung benutzt einen FESTEN Zeitpunkt als Eingabe, keine
Wanderuhr -- sonst misst sie irgendwann das Gegenteil.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-15 13:22:55 +02:00

1513 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ===================================================================
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;
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. */
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 = '<path d="M20.4 12.4c0 3.8-3.8 6.9-8.4 6.9-1 0-2-.15-2.9-.42L4 20.4l1.6-3.6a6.6 6.6 0 0 1-2-4.4C3.6 8.6 7.4 5.5 12 5.5s8.4 3.1 8.4 6.9z"/>';
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 <dialog> 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() {
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 <details>, 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 = [];
/* 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. */
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 = '';
}
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 || [];
kategorien = daten.kategorien || [];
/* `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');
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 || '';
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 || '';
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 <dialog> 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 = {
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 || '';
$('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();
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);
/* Bild ans <option> -- wahl.js zeichnet daraus das Gesicht.
Diese zwei Zeilen standen beim ersten Anlauf am DATEIENDE,
ausserhalb der Schleife: `o is not defined`, gemeldet von
pruef-sicht, das die Browserkonsole mitliest. Ein
Suchmuster ueber mehrere Dateien trifft eben nicht immer
die Stelle, die man meint. */
o.dataset.person = 'ja';
if (p.bild) o.dataset.bild = p.bild;
$(ziel).append(o);
}
}
} catch { /* Zuordnung bleibt dann leer -- kein Grund abzubrechen */ }
/* Aussuchen ODER selbst eintragen (02.09.2026). Der Hinweis
erscheint erst, wenn wirklich ein freier Name drinsteht -- ein
dauerhafter Warnsatz wird nach zwei Tagen überlesen. */
for (const ziel of ['f-creator', 'b-creator']) {
window.personenwahl?.(ziel, { hinweis: "Kein Konto im Workspace – der Eintrag gehört dann zu keiner Person und taucht in personenbezogenen Auswertungen nicht auf. Sichtbar bleibt er für dich." });
}
for (const ziel of ['f-verant', 'b-verant']) {
window.personenwahl?.(ziel, { hinweis: "Kein Konto im Workspace – der Name steht nur an diesem Eintrag."
+ ' Die Aufgabe steht dann auf keinem persönlichen Aufgabenbrett.' });
}
/* Das Kategorie-Feld folgt der gewaehlten Person. Auf `change` und
nicht einmalig beim Oeffnen: Wer erst den Titel tippt und dann
die Person aussucht, bekaeme das Feld sonst nie zu sehen. */
for (const ziel of ['f-creator', 'f-verant']) {
$(ziel)?.addEventListener('change', () => kategorieFeldZeigen('f'));
}
for (const ziel of ['b-creator', 'b-verant']) {
$(ziel)?.addEventListener('change', () => kategorieFeldZeigen('b'));
}
}
await laden();
await vorlagenbrettZeigen();
})();
/* =====================================================================
AUFGABEN AUS DER VORLAGE
Vier Bereiche, vier Stufen, drei Aufgaben je Feld. Der Block ist
bewusst KEINE zweite Aufgabenverwaltung: Er legt eine ganz normale
Aufgabe an, mit Frist und Verantwortlichem, und verschwindet danach
aus dem Weg. Alles Weitere macht das Brett darunter.
WARUM DIE BEIDEN REIHEN DIESELBEN KNOEPFE SIND WIE IN DER
CHECKLISTE: Es sind dieselben vier Bereiche und dieselben vier
Stufen. Zwei verschiedene Darstellungen fuer dieselbe Einteilung
waeren zwei Sachen zum Lernen statt einer.
OHNE CREATOR KEINE AUFGABE. Ein Creator legt sie fuer sich an, ein
Betreuer fuer den, den er oben gewaehlt hat. Ist keiner gewaehlt,
steht das da -- statt eines Knopfes, der beim Druecken einen Fehler
zeigt.
===================================================================== */
const BEREICHE_VORLAGE = [
{ schluessel: 'live', name: 'LIVE-Analyse' },
{ schluessel: 'content', name: 'Content-Ideen' },
{ schluessel: 'technik', name: 'Technik' },
{ schluessel: 'community', name: 'Community' },
];
const STUFEN_VORLAGE = [
{ schluessel: 'anfaenger', name: 'Anfänger',
hilfe: 'Was von Anfang an sitzen muss. Meist an einem Abend zu schaffen.' },
{ schluessel: 'fort', name: 'Fortgeschritten',
hilfe: 'Aus Zufall wird Routine. Braucht Wiederholung, also Wochen.' },
{ schluessel: 'profi', name: 'Profi',
hilfe: 'Gemessen statt geschätzt: ein Wert, ein Versuch, ein Beleg.' },
{ schluessel: 'meister', name: 'Meister',
hilfe: 'Was auch ohne dich weiterläuft — Vorlage, Vertretung, Ablage.' },
];
let vorlagen = null;
/* Der zweite Katalog -- welcher es ist, sagt der Server. Hier steht
bewusst nur "katalog": Im Browser darf nichts stehen, woraus sich
schliessen laesst, fuer wen er gedacht ist. */
let katalog = null;
let kGruppe = null;
/* Getrennt vom Inhalt, weil "nichts bekommen" ein gueltiges Ergebnis
ist. Ohne dieses Merkmal fragte die Seite bei jedem Neuzeichnen
erneut nach -- und bekaeme jedes Mal wieder nichts. */
let vorlagenGeladen = false;
let vBereich = 'live';
let vStufe = 'anfaenger';
/* ---------- DAS VORLAGENBRETT IST ZUGEKLAPPT (09.09.2026) ------------
Filipe: "ich will nur dass du dich informierst und schaust was man
vielleicht noch besser machen koennte von der benutzung ... ich red
von den aufgaben. das system von den aufgaben, die bedienung."
Gemessen auf dem Handy: Der Vorlagenblock fuellte anderthalb
Bildschirme, BEVOR die erste eigene Aufgabe kam. Nach dem Ausbau
von Profi und Meister am selben Tag ist er noch laenger geworden.
Das ist die falsche Reihenfolge: Vorlagen holt man selten, das
Brett benutzt man jeden Tag. Der Block bleibt an seiner Stelle --
dort gehoert er hin, weil daraus Aufgaben auf das Brett darunter
wandern --, aber er ist jetzt zu. Wer ihn aufmacht, findet ihn
beim naechsten Mal wieder offen: Wer Vorlagen holt, holt meistens
mehrere. */
const VORLAGEN_OFFEN = 'dfw_vorlagenbrett_offen';
let vOffen = false;
try { vOffen = localStorage.getItem(VORLAGEN_OFFEN) === 'ja'; } catch { vOffen = false; }
async function vorlagenbrettZeigen() {
const ziel = $('vorlagenbrett');
if (!ziel || !ich) return;
/* WER DIESEN KATALOG BEKOMMT, ENTSCHEIDET DER SERVER.
Hier stand kurzzeitig ein Vergleich auf einen Rollennamen -- in
einer Datei, die JEDER bekommt, der die Seite oeffnet. Jetzt
liefert /workspace/api/vorlagen fuer die Betroffenen schlicht
nichts, und `if (!vorlagen) return` weiter unten laesst das Brett
verborgen. Dieselbe Wirkung, ohne eine Zeile, die verraet, fuer
wen sie gilt. */
if (!vorlagenGeladen) {
try {
const a = await hole('/workspace/api/vorlagen');
if (!a.ok) return;
const d = await a.json();
vorlagen = d.aufgaben || null;
/* Ein Katalog mit null Eintraegen ist keiner. Ohne diese Pruefung
haette ein leeres `{gruppen:[],aufgaben:[]}` einen Block mit
Ueberschrift und nichts darin ergeben -- das sieht aus wie ein
Fehler, und es waere auch einer. */
katalog = d.katalog && Array.isArray(d.katalog.aufgaben) && d.katalog.aufgaben.length
? d.katalog : null;
vorlagenGeladen = true;
} catch { return; }
}
/* Zwei Kataloge, einer je Person -- nie beide. Wer den einen
bekommt, bekommt den anderen nicht; das entscheidet der Server. */
if (katalog) { katalogBrett(ziel); return; }
if (!vorlagen) return;
ziel.hidden = false;
ziel.textContent = '';
/* DER KOPF IST DER KNOPF. Ein eigener kleiner Schalter daneben waere
ein zweites Ziel fuer dieselbe Absicht -- und ein kleineres. */
const kopf = el('button', 'vorlagenbrett__kopf');
kopf.type = 'button';
kopf.setAttribute('aria-expanded', String(vOffen));
kopf.setAttribute('aria-controls', 'vorlagenbrett-koerper');
const kText = el('span', 'vorlagenbrett__kopftext');
kText.append(el('span', 'feldschild', 'Aufgaben aus der Vorlage'));
/* Zugeklappt zaehlt nicht die Erklaerung, sondern WIE VIEL darin
liegt -- sonst sieht der Block aus wie eine Ueberschrift ohne
Inhalt und niemand macht ihn je auf. */
const gesamt = Object.values(vorlagen)
.reduce((n, b) => n + Object.values(b).reduce((m, l) => m + l.length, 0), 0);
kText.append(el('span', 'vorlagenbrett__unter', vOffen
? 'Vier Bereiche, vier Stufen. Übernommen wird als ganz normale Aufgabe '
+ '– mit Frist, veränderbar, auf dem Brett darunter.'
: `${gesamt} fertige Aufgaben in vier Bereichen und vier Stufen – zum Übernehmen.`));
kopf.append(kText);
const pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
pfeil.setAttribute('viewBox', '0 0 24 24');
pfeil.setAttribute('class', 'klapp-pfeil');
pfeil.setAttribute('aria-hidden', 'true');
const pfad = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pfad.setAttribute('d', 'm6 9.5 6 6 6-6');
pfeil.append(pfad);
kopf.append(pfeil);
kopf.addEventListener('click', () => {
vOffen = !vOffen;
try { localStorage.setItem(VORLAGEN_OFFEN, vOffen ? 'ja' : 'nein'); } catch { /* egal */ }
vorlagenbrettZeigen();
});
ziel.append(kopf);
ziel.dataset.auf = vOffen ? 'ja' : 'nein';
/* Zugeklappt wird der Rest gar nicht erst gebaut. Ihn nur zu
verstecken hiesse, bei jedem Neuzeichnen achtzig Karten
anzulegen, die niemand sieht. */
if (!vOffen) return;
const koerper = el('div', 'vorlagenbrett__koerper');
koerper.id = 'vorlagenbrett-koerper';
ziel.append(koerper);
/* Reihe 1: der Bereich. */
const r1 = el('div', 'stufenleiter');
r1.setAttribute('role', 'group');
r1.setAttribute('aria-label', 'Bereich wählen');
for (const b of BEREICHE_VORLAGE) {
const k = el('button', 'stufenleiter__knopf');
k.type = 'button';
k.dataset.stufe = b.schluessel;
k.setAttribute('aria-pressed', String(vBereich === b.schluessel));
k.append(el('span', 'stufenleiter__name', b.name));
k.addEventListener('click', () => { vBereich = b.schluessel; vorlagenbrettZeigen(); });
r1.append(k);
}
koerper.append(r1);
/* Reihe 2: die Stufe, mit der Zahl der Aufgaben darin. */
const r2 = el('div', 'stufenleiter');
r2.setAttribute('role', 'group');
r2.setAttribute('aria-label', 'Könnensstufe wählen');
for (const st of STUFEN_VORLAGE) {
const n = ((vorlagen[vBereich] || {})[st.schluessel] || []).length;
if (!n) continue;
const k = el('button', 'stufenleiter__knopf');
k.type = 'button';
k.dataset.stufe = st.schluessel;
k.setAttribute('aria-pressed', String(vStufe === st.schluessel));
k.title = st.hilfe;
k.append(el('span', 'stufenleiter__name', st.name));
k.append(el('span', 'stufenleiter__zahl', String(n)));
k.addEventListener('click', () => { vStufe = st.schluessel; vorlagenbrettZeigen(); });
r2.append(k);
}
koerper.append(r2);
const hilfe = STUFEN_VORLAGE.find((x) => x.schluessel === vStufe)?.hilfe;
if (hilfe) koerper.append(el('p', 'stufenleiter__hilfe', hilfe));
/* Für wen? */
const fuer = ich.rolle === 'creator' ? ich.id : nurCreator;
if (!fuer) {
koerper.append(el('p', 'vorlagenbrett__hinweis',
'Wähle oben einen Creator, dann lassen sich diese Aufgaben übernehmen.'));
}
/* WAS SCHON GEHOLT WURDE, STEHT DA (09.09.2026).
Vorher konnte man dieselbe Vorlage zweimal uebernehmen und
bekam die Aufgabe zweimal auf das Brett -- ohne jeden Hinweis.
Bei achtzig Vorlagen ueber vier Stufen weiss niemand auswendig,
was er letzten Monat schon geholt hat.
Verglichen wird ueber die KENNUNG der Vorlage, nicht ueber den
Titel: Ein Titel darf geaendert werden, und dann waere der
Vergleich still falsch. Die Kennung liegt seit heute an der
Aufgabe (Spalte `vorlage`).
Es braucht dafuer keine zusaetzliche Abfrage -- die Aufgaben
liegen ohnehin schon im Browser. */
const schonDa = new Map();
for (const a of alleAufgaben) {
if (!a.vorlage) continue;
if (fuer && a.creator_id !== fuer) continue;
schonDa.set(a.vorlage, a);
}
const liste = (vorlagen[vBereich] || {})[vStufe] || [];
const box = el('div', 'vorlagenbrett__liste');
for (const [nr, v] of liste.entries()) {
const karte = el('div', 'vorlagenbrett__karte');
const schon = schonDa.get(v.schluessel);
if (schon) karte.dataset.schon = 'ja';
karte.append(el('h3', 'vorlagenbrett__titel', v.titel));
karte.append(el('p', 'vorlagenbrett__text', v.text));
const fuss = el('div', 'vorlagenbrett__fuss');
fuss.append(el('span', 'vorlagenbrett__frist',
v.tage === 1 ? 'Frist: morgen' : `Frist: in ${v.tage} Tagen`));
if (schon) {
/* Der Zustand steht dabei, nicht nur die Tatsache. "Schon
uebernommen" allein liesse offen, ob sie noch offen ist oder
laengst abgehakt -- und danach entscheidet man, ob man sie
ein zweites Mal holt. */
fuss.append(el('span', 'vorlagenbrett__schon',
`schon übernommen · ${STATUS_NAME[schon.status] || schon.status}`));
}
if (fuer) {
const k = el('button', 'knopf knopf--klein', schon ? 'Nochmal' : 'Übernehmen');
k.type = 'button';
if (schon) k.title = 'Diese Vorlage liegt schon auf dem Brett – noch einmal übernehmen?';
k.addEventListener('click', () => uebernehmen(k, { art: 'aufgabe', nr }, fuer));
fuss.append(k);
}
karte.append(fuss);
box.append(karte);
}
/* Und wie viele der Stufe noch offen sind -- die Zahl, nach der man
eigentlich sucht, wenn man den Block aufmacht. */
if (liste.length) {
const offen = liste.filter((v) => !schonDa.has(v.schluessel)).length;
koerper.append(el('p', 'vorlagenbrett__stand', offen
? `${offen} von ${liste.length} noch nicht übernommen.`
: `Alle ${liste.length} dieser Stufe sind übernommen.`));
}
koerper.append(box);
if (fuer && liste.length > 1) {
const alle = el('button', 'abmelden', `Alle ${liste.length} übernehmen`);
alle.title = 'Legt alle Aufgaben dieser Stufe an – auch die, die schon auf dem Brett liegen.';
alle.type = 'button';
alle.style.marginTop = '12px';
alle.addEventListener('click', () => uebernehmen(alle, { art: 'aufgaben_alle' }, fuer));
koerper.append(alle);
}
}
/* =====================================================================
DAS ZWEITE VORLAGENBRETT (10.09.2026)
Derselbe Block, derselbe Kopf, dieselben Karten -- nur eine andere
Einteilung: neun Etappen statt vier Bereichen mal vier Stufen. Die
Bausteine sind bewusst dieselben (vorlagenbrett__karte,
stufenleiter, ...): Ein zweites Aussehen fuer dieselbe Sache waere
zwei Dinge zum Lernen statt einem -- und eine zweite Stelle, an der
jemand eine neue CSS-Klasse in sieben Kopien pflegen muesste.
WAS DRINSTEHT, KOMMT VOM SERVER, samt Gruppennamen und Texten. In
dieser Datei steht kein Wort darueber, fuer wen dieser Katalog ist.
===================================================================== */
function katalogBrett(ziel) {
ziel.hidden = false;
ziel.textContent = '';
if (!kGruppe) kGruppe = (katalog.gruppen[0] || {}).schluessel || null;
const kopf = el('button', 'vorlagenbrett__kopf');
kopf.type = 'button';
kopf.setAttribute('aria-expanded', String(vOffen));
kopf.setAttribute('aria-controls', 'vorlagenbrett-koerper');
const kText = el('span', 'vorlagenbrett__kopftext');
kText.append(el('span', 'feldschild', 'Aufgaben aus der Vorlage'));
kText.append(el('span', 'vorlagenbrett__unter', vOffen
? 'Nach Etappen sortiert. Übernommen wird als ganz normale Aufgabe '
+ '– mit Frist und Kategorie, veränderbar, auf dem Brett darunter.'
: `${katalog.aufgaben.length} fertige Aufgaben in `
+ `${katalog.gruppen.length} Etappen – zum Übernehmen.`));
kopf.append(kText);
const pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
pfeil.setAttribute('viewBox', '0 0 24 24');
pfeil.setAttribute('class', 'klapp-pfeil');
pfeil.setAttribute('aria-hidden', 'true');
const pfad = document.createElementNS('http://www.w3.org/2000/svg', 'path');
pfad.setAttribute('d', 'm6 9.5 6 6 6-6');
pfeil.append(pfad);
kopf.append(pfeil);
kopf.addEventListener('click', () => {
vOffen = !vOffen;
try { localStorage.setItem(VORLAGEN_OFFEN, vOffen ? 'ja' : 'nein'); } catch { /* egal */ }
vorlagenbrettZeigen();
});
ziel.append(kopf);
ziel.dataset.auf = vOffen ? 'ja' : 'nein';
/* Zugeklappt wird der Rest gar nicht erst gebaut -- sonst lägen bei
jedem Neuzeichnen sechzig Karten da, die niemand sieht. */
if (!vOffen) return;
const koerper = el('div', 'vorlagenbrett__koerper');
koerper.id = 'vorlagenbrett-koerper';
ziel.append(koerper);
/* Die Etappen als Reihe -- mit der Zahl darin, damit man sieht, wo
etwas liegt, bevor man klickt. */
const reihe = el('div', 'stufenleiter');
reihe.setAttribute('role', 'group');
reihe.setAttribute('aria-label', 'Etappe wählen');
for (const g of katalog.gruppen) {
const n = katalog.aufgaben.filter((v) => v.gruppe === g.schluessel).length;
if (!n) continue;
const k = el('button', 'stufenleiter__knopf');
k.type = 'button';
k.dataset.stufe = g.schluessel;
k.setAttribute('aria-pressed', String(kGruppe === g.schluessel));
k.append(el('span', 'stufenleiter__name', g.name));
k.append(el('span', 'stufenleiter__zahl', String(n)));
k.addEventListener('click', () => { kGruppe = g.schluessel; vorlagenbrettZeigen(); });
reihe.append(k);
}
koerper.append(reihe);
const gruppe = katalog.gruppen.find((g) => g.schluessel === kGruppe);
if (gruppe?.text) koerper.append(el('p', 'vorlagenbrett__stand', gruppe.text));
/* Was schon auf dem Brett liegt -- aus den Aufgaben, die ohnehin
geladen sind. Verglichen wird die Kennung der Vorlage und nicht
der Titel: Der darf geändert werden. */
const schonDa = new Map();
for (const a of alleAufgaben) if (a.vorlage) schonDa.set(a.vorlage, a);
const liste = katalog.aufgaben
.map((v, nr) => ({ ...v, nr }))
.filter((v) => v.gruppe === kGruppe);
const box = el('div', 'vorlagenbrett__liste');
for (const v of liste) {
const karte = el('div', 'vorlagenbrett__karte');
const schon = schonDa.get(v.schluessel);
if (schon) karte.dataset.schon = 'ja';
karte.append(el('h3', 'vorlagenbrett__titel', v.titel));
karte.append(el('p', 'vorlagenbrett__text', v.text));
const fuss = el('div', 'vorlagenbrett__fuss');
fuss.append(el('span', 'vorlagenbrett__frist',
v.tage === 1 ? 'Frist: morgen' : `Frist: in ${v.tage} Tagen`));
/* Die empfohlene Rolle steht im Dokument neben jeder Aufgabe und
ist die halbe Auskunft: "Technik-Check" heisst etwas anderes,
je nachdem, wer ihn macht. */
if (v.rolle) fuss.append(el('span', 'vorlagenbrett__frist', `für: ${v.rolle}`));
if (schon) {
fuss.append(el('span', 'vorlagenbrett__schon',
`schon übernommen · ${STATUS_NAME[schon.status] || schon.status}`));
}
const k = el('button', 'knopf knopf--klein', schon ? 'Nochmal' : 'Übernehmen');
k.type = 'button';
if (schon) k.title = 'Diese Vorlage liegt schon auf dem Brett – noch einmal übernehmen?';
k.addEventListener('click', () => uebernehmen(k,
{ art: 'katalog', nr: v.nr, gruppe: kGruppe, person_id: katalogZiel() }));
fuss.append(k);
karte.append(fuss);
box.append(karte);
}
if (liste.length) {
const offen = liste.filter((v) => !schonDa.has(v.schluessel)).length;
koerper.append(el('p', 'vorlagenbrett__stand', offen
? `${offen} von ${liste.length} noch nicht übernommen.`
: `Alle ${liste.length} dieser Etappe sind übernommen.`));
}
koerper.append(box);
if (liste.length > 1) {
const alle = el('button', 'abmelden', `Alle ${liste.length} übernehmen`);
alle.title = 'Legt alle Aufgaben dieser Etappe an – auch die, die schon auf dem Brett liegen.';
alle.type = 'button';
alle.style.marginTop = '12px';
alle.addEventListener('click', () => uebernehmen(alle,
{ art: 'katalog_alle', gruppe: kGruppe, person_id: katalogZiel() }));
koerper.append(alle);
}
}
/* FUER WEN wird uebernommen? Wer nur eigene Aufgaben hat, meint sich
selbst -- der Server setzt das dann ein und braucht hier nichts.
Wer fuer andere eintragen darf, muss die Person oben ausgewaehlt
haben; sonst sagt der Server es. */
function katalogZiel() {
return $('f-verant')?.value || $('f-creator')?.value || null;
}
/* Ein Weg fuer beide Knoepfe -- einzeln und alle. Zwei fast gleiche
Funktionen waeren der sichere Weg dazu, dass die eine irgendwann
anders reagiert als die andere. */
async function uebernehmen(knopf, was, fuer) {
knopf.disabled = true;
const vorher = knopf.textContent;
knopf.textContent = 'Wird übernommen …';
try {
const a = await hole('/workspace/api/vorlagen/uebernehmen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...was, bereich: vBereich, stufe: vStufe, creator_id: fuer }),
});
if (!a.ok) {
const f = await a.json().catch(() => ({}));
melde(f.fehler || 'Das hat nicht geklappt.');
knopf.disabled = false; knopf.textContent = vorher;
return;
}
melde('');
/* Erst das Brett neu laden, dann den Block -- so sieht man die
neue Aufgabe sofort dort, wo sie ab jetzt lebt. */
await laden();
await vorlagenbrettZeigen();
} catch {
melde('Das hat nicht geklappt.');
knopf.disabled = false; knopf.textContent = vorher;
}
}
})();
/* =====================================================================
ANHAENGE AN EINER AUFGABE (15.09.2026, Stufe 9)
KEIN ZWEITER HOCHLADEWEG. Die Datei geht an denselben Weg wie in der
Dateiablage (/workspace/api/dateien) und bekommt nur einen Kopf mehr:
die Nummer der Aufgabe. Sichtbarkeit, Freigaben, Fassungen und
Protokoll bleiben damit genau dort, wo sie schon richtig sind.
WAS BEIM LOESCHEN DER AUFGABE PASSIERT, steht in der Datenbank
(ON DELETE SET NULL) und nicht hier: Die Datei bleibt, sie verliert
nur ihren Bezug. Wer eine Aufgabe loescht, will die Aufgabe loeschen.
===================================================================== */
(() => {
const $ = (id) => document.getElementById(id);
const el = (t, k, text) => {
const n = document.createElement(t);
if (k) n.className = k;
if (text !== undefined) n.textContent = text;
return n;
};
const feld = $('b-datei');
const liste = $('b-anhaenge');
const stand = $('b-anhang-stand');
if (!feld || !liste) return;
let fuerAufgabe = null;
const groesse = (n) => (n >= 1048576
? (n / 1048576).toFixed(1) + ' MB'
: Math.max(1, Math.round(n / 1024)) + ' KB');
const sagen = (t) => { if (stand) stand.textContent = t || ''; };
window.anhaengeLaden = async function (aufgabeId) {
fuerAufgabe = aufgabeId;
liste.textContent = '';
sagen('');
try {
const a = await fetch('/workspace/api/aufgaben/' + aufgabeId + '/dateien',
{ credentials: 'same-origin' });
if (!a.ok) return;
const d = await a.json();
if (!d.dateien?.length) {
liste.append(el('span', 'anhaenge__leer', 'Noch nichts angehängt.'));
return;
}
for (const x of d.dateien) {
const v = el('a', 'anhaenge__datei');
/* Der Herunterlade-Weg heisst /inhalt -- /dateien/:id allein
gibt es nicht. Beim ersten Anlauf stand hier der Verweis ins
Leere; gefunden hat es der Blick in die Routen, nicht der
Klick. */
v.href = '/workspace/api/dateien/' + x.id + '/inhalt';
v.append(el('span', 'anhaenge__name', x.name_original));
v.append(el('span', 'anhaenge__groesse', groesse(x.groesse)));
/* Eine ueberholte Fassung ALS SOLCHE kennzeichnen -- sonst laedt
jemand hier die alte herunter und merkt es nicht. Genau der
Fall, fuer den es die Fassungen gibt. */
if (x.ersetzt_durch) {
const w = el('span', 'anhaenge__alt', 'nicht mehr aktuell');
w.title = 'Es gibt eine neuere Fassung dieser Datei.';
v.append(w);
} else if (x.fassung > 1) {
v.append(el('span', 'anhaenge__fassung', 'Fassung ' + x.fassung));
}
liste.append(v);
}
} catch { /* der Dialog ist auch ohne Anhaenge benutzbar */ }
};
feld.addEventListener('change', async () => {
const f = feld.files?.[0];
if (!f || !fuerAufgabe) return;
sagen('Wird hochgeladen …');
try {
const a = await fetch('/workspace/api/dateien', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': f.type || 'application/octet-stream',
'x-dateiname': encodeURIComponent(f.name),
'x-aufgabe': String(fuerAufgabe),
},
body: f,
});
if (!a.ok) {
const j = await a.json().catch(() => ({}));
sagen(j.fehler === 'zu_gross'
? 'Die Datei ist zu groß.'
: (j.fehler || 'Das hat nicht geklappt.'));
return;
}
sagen('Angehängt.');
await window.anhaengeLaden(fuerAufgabe);
} catch {
sagen('Keine Verbindung.');
} finally {
/* Zuruecksetzen, sonst loest dieselbe Datei kein zweites Mal aus. */
feld.value = '';
}
});
})();