Files
dogfather-universe/workspace/assets/js/aufgaben.js
T
DogFatherGitandClaude Opus 5 e8478c9cae Spicy sieht DogFathers Sachen nicht mehr -- und sechs Fehler dazu
VIER DINGE AUS DEN BILDSCHIRMFOTOS, jedes ein echter Fehler:

1. SPICY MEDIA SAH DOGFATHERS DATEN. Beim ersten Anlauf bekam die Rolle
   dieselbe Regel wie DogFather (`1=1`) -- damit stimmte der Ueberblick
   ueber Manager, Scouts und Creator, und nebenbei standen seine eigenen
   Termine, Aufgaben, Dateien und Eintraege mit drin. Jetzt gibt es
   ohneDogFather() als EINE Stelle dafuer: Zwei Spalten werden geprueft,
   `creator_id` (um wen geht es) und `erstellt_von` (wer hat es
   geschrieben) -- ein Termin, den er sich selbst anlegt, haengt nur an
   der zweiten. `IS NULL OR NOT IN` und nicht bloss `NOT IN`: In SQL ist
   `NULL NOT IN (...)` weder wahr noch falsch, die Zeile fiele
   stillschweigend heraus.

2. "PERSOENLICHER ZUGANGSCODE · undefined" auf der Anmeldeseite. Eine
   zweite Namensliste im Browser, die beim Hinzufuegen der Rolle
   niemand gepflegt hat. Ein unbekannter Schluessel faellt jetzt auf
   sich selbst zurueck statt auf `undefined` -- haesslich, aber
   sichtbar.

3. SPICY KAM NICHT IN DIE PERSONENVERWALTUNG. Der Server liess sie
   herein, das Skript warf sie wieder hinaus: zwei Listen fuer dieselbe
   Frage, gepflegt wurde nur die erste.

4. DIE ANMELDEKARTE WAR ZU KLEIN FUER FUENF ROLLEN. Der Kommentar an
   genau dieser Stelle warnt woertlich davor -- und ich habe getan,
   wovor er warnt: eine Rolle eingefuegt und die Zahl daneben nicht
   angefasst. Gemessen: Inhalt 573 px in einer 526 px hohen Tafel, die
   Fusszeile stand unter dem Rahmen. Schrift kleiner half nicht (sie lag
   schon auf dem Anschlag), also sind die Abstaende an neun Stellen
   enger. Nachgemessen auf fuenf Groessen: passt ueberall.

DIE ZEIT WAR WIRKLICH FALSCH. Nicht nur in meinem Satz: `datum()` in
personen.js schnitt die ISO-Zeichenkette ab -- und die ist UTC. Im
Protokoll stand 03:00, wo 05:00 war. Das Tueckische daran ist, dass es
nie kaputt aussieht: Eine Uhrzeit ist immer plausibel.

PROFILBILDER WERDEN JETZT GEZEICHNET. Der Server lieferte sie seit
gestern mit, gezeichnet wurden sie nirgends -- deshalb aenderte sich
nichts. Jetzt in der Personenauswahl (Aufgaben, Termine, Dateien,
Bereiche), in der Gespraechsliste und an jeder Nachricht. Der
Anfangsbuchstabe bleibt als Unterlage LIEGEN: Faellt das Bild aus, steht
dort weiter etwas Sinnvolles.

DER CHAT: Gesichter mit Rollenfarbe, Blasen mit Richtung (die erste
einer Folge eckig, die naechsten rund -- so sieht man, wo ein Gedanke
anfaengt), das offene Gespraech mit Schiene, Ungelesenes hervorgehoben,
und das Eingabefeld in einer eigenen Leiste, die sich beim Schreiben
hebt.

ZWEI EIGENE PATZER, beide von Pruefungen gefunden:
  - `o is not defined`: Mein Suchmuster hat die zwei Zeilen fuer das
    Bild ans DATEIENDE gesetzt statt in die Schleife -- und in
    kalender.js an einen <span> statt ans <option>. Gemeldet von
    pruef-sicht, das die Browserkonsole mitliest.
  - Ein Kommentar mit `bild` in schraegen Anfuehrungszeichen stand INNEN
    in einer Vorlagenzeichenkette und hat sie geschlossen. Die halbe SQL
    wurde zu Programmtext.
Dazu: `.chat-neu` gibt es nicht (heisst `.chat__eingabe`), und der
Rollenknopf hatte fuer den Manager keinen sichtbaren Fokus -- ein
box-shadow wird von `overflow: hidden` abgeschnitten, ein outline nicht.

Gruen: spicy (43), creator-anlegen (29), personen-liste (33), sicht (48),
chat (48), chat-optik (24), buehne (38), struktur (32), css-klassen (15),
handy (59), breiten (23), formulare (19), start-ansicht (136),
lesbarkeit (14), barrierefrei (18), tempo (8), kopf-messen (3),
glocke (26), haerte (20), manager-sicht (43), alle-wege (19),
aufgabenbrett (44), kalender (84).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 05:46:19 +02:00

802 lines
31 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. */
const LEITUNG = new Set(['admin', 'manager']);
/* `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. */
const DARF_ABBRECHEN = new Set(['admin', 'manager', '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');
if (a.frist) {
const faellig = a.status !== 'erledigt' && a.frist < heute;
const f = el('span', 'karte__frist' + (faellig ? ' karte__frist--faellig' : ''),
(faellig ? 'überfällig ' : 'bis ') + datumHuebsch(a.frist));
zeile.append(f);
}
if (a.verantwortlich_name) zeile.append(el('span', null, '· ' + a.verantwortlich_name));
if (LEITUNG.has(ich.rolle) && a.creator_name && a.creator_name !== a.verantwortlich_name) {
zeile.append(el('span', null, '· Bereich ' + a.creator_name));
}
if (zeile.childElementCount) k.append(zeile);
const schritte = el('div', 'karte__schritte');
if (ZURUECK[a.status]) schritte.append(knopf('◀ zurück', a, ZURUECK[a.status]));
if (WEITER[a.status]) schritte.append(knopf(beschriftung(a.status), a, WEITER[a.status]));
/* 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';
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. */
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',
};
function sprungZeigen() {
const was = window.zielSchluessel?.();
if (!was || !ZEIG_TEXT[was]) return;
const treffer = was === 'ueberfaellig'
? document.querySelectorAll('.karte[data-faellig="ja"]')
: document.querySelectorAll(`.karte[data-status="${was}"]`);
window.zielMarkieren?.(treffer, ZEIG_TEXT[was]);
}
async function laden() {
try {
const antwort = await hole('/workspace/api/aufgaben');
if (!antwort.ok) { melde('Aufgaben konnten nicht geladen werden.'); return; }
alleAufgaben = (await antwort.json()).aufgaben || [];
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;
}
knopfEl.disabled = true;
try {
const antwort = await hole('/workspace/api/aufgaben', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(daten),
});
if (!antwort.ok) {
const d = await antwort.json().catch(() => ({}));
$('neu-fehler').textContent = d.fehler || 'Anlegen hat nicht geklappt.';
return;
}
$('neu').reset();
formularUmschalten(false);
await laden();
} catch { /* bereits umgeleitet */ } finally { knopfEl.disabled = false; }
});
/* ---------- Bearbeiten ------------------------------------------------ */
const dialog = $('bearbeiten-dialog');
let bearbeitet = null;
function dialogOeffnen(a) {
bearbeitet = a;
$('b-titel').value = a.titel;
$('b-text').value = a.beschreibung || '';
$('b-prio').value = a.prioritaet;
$('b-frist').value = a.frist || '';
$('b-fehler').textContent = '';
if (LEITUNG.has(ich.rolle)) {
$('b-creator').value = a.creator_id ? String(a.creator_id) : '';
$('b-verant').value = a.verantwortlich_id ? String(a.verantwortlich_id) : '';
$('b-loeschen').hidden = false;
}
dialog.showModal();
$('b-titel').focus();
}
$('b-abbrechen').addEventListener('click', () => dialog.close());
/* 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;
}
$('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.' });
}
}
await laden();
})();
})();