Files
dogfather-universe/workspace/assets/js/aufgaben.js
T
DogFatherGitandClaude Opus 5 95c4599baa Anfuehrungszeichen: 199 falsche Schlusszeichen im sichtbaren Text
Deutsch oeffnet mit „ und schliesst mit “. An 199 Stellen, die ein
Mensch liest, stand als Schlusszeichen ein GERADES " -- „Nächstes"
statt „Nächstes“. Auf sechzehn oeffentlichen Seiten, in vierzig
Dateien des Workspace und in zwoelf Servermodulen, die Texte
verschicken.

Auf dem Bildschirm sieht man den Unterschied sofort. Beim Schreiben
nicht: Das gerade " liegt auf der Tastatur, die anderen nicht.

WARUM EIN ERSTER ANLAUF ZURUECKGENOMMEN WURDE

Ein gerades " ist an vielen Stellen SYNTAX und kein Schriftzeichen --
Grenze einer Zeichenkette, Grenze eines HTML-Attributs, Zeichen in
einem regulaeren Ausdruck. Wer stumpf ersetzt, macht aus

    „<a href="https://…                  ein kaputtes Attribut
    /^["'„»\s]+|["'“«.\s]+$/              einen kaputten Ausdruck
    "… nichts „mal " + "eben …"          eine kaputte Zeichenkette

DIE UNTERSCHEIDUNG LAEUFT AN MERKMALEN, NICHT AN EINER LISTE

  < > = dazwischen        -> HTML-Marke oder Attribut
  endet auf Leerzeichen   -> die Zeichenkette hoert hier auf, der
                             Satz geht in der naechsten Zeile weiter.
                             Ein deutsches Schlusszeichen steht NIE
                             hinter einem Leerzeichen.
  Rueckstrich mittendrin  -> regulaerer Ausdruck
  ${ ohne }               -> mitten in einem Ausdruck

Eine Liste erlaubter Ausnahmen waere die naechste, die niemand
pflegt. Zwoelf Stellen bleiben dadurch stehen, alle zwoelf einzeln
angesehen und alle zwoelf zu Recht -- dort steht das richtige
Schlusszeichen ohnehin weiter unten im Satz.

Fuenf davon waren allerdings ECHTE Fehler HINTER dem Link
(`…>HasiDog</a>".`) -- die erste Regel hatte nur das Attribut
gesehen, nicht den Satz danach. Gezielt nachgezogen.

Ein maskiertes `\"` in workspace-vorlagen.js (28 Hooks) wird zu “ --
ohne Rueckstrich, denn “ begrenzt nichts.

KOMMENTARE BLEIBEN, WIE SIE SIND. Dort liest es niemand ausser mir;
eine Wache, die auch Kosmetik anmahnt, wird weggeklickt. Beim ersten
Messen fielen ausserdem acht Stellen aus buehne.html faelschlich an,
weil `/* */` in HTML (in <style> und <script>) nicht ausgeblendet
war -- jetzt schon.

DIE WACHE DAZU

pruef-struktur prueft es ab sofort mit derselben Regel: 322 Dateien
mit sichtbarem Text, 0 Funde, und die zwoelf bewussten Ausnahmen
werden GEZAEHLT und genannt (erlaubt: 12). Eine Ausnahme, die niemand
sieht, waechst -- und irgendwann steht der echte Fall darin.

GEPRUEFT

  pruef-struktur   59 -> 68 Pruefungen, 0 Fehler
  node --check auf allen geaenderten JS-Dateien
  nachgemessen: 199 geaendert, 12 mit Grund stehen geblieben

  gruen geblieben: bewerbung-aufgaben 163, nachwuchs 262,
  reaktion 421, support 63, content 45, terminregel 35, treff 85

  Und nachgesehen, ob eine Pruefung noch die alte Schreibweise
  ERWARTET: 13 Fundstellen, alle dreizehn nur Text in ihrer eigenen
  Ausgabe, keine einzige ein Vergleich mit dem Seitentext.

GEGENPROBE: In reaktion.html ein Schlusszeichen zurueckgedreht ->
„workspace/reaktion.html:546 „Nächstes"", mit Datei und Zeile. Und
die Erkennung einzeln gegen HTML-Attribut, fortgesetzte
Zeichenkette, regulaeren Ausdruck und eingesetzten Wert geprueft.

Stempel gesetzt: workspace 670 Verweise in 45 Dateien, oeffentlich
554 in 48 Seiten.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 01:56:15 +02:00

2247 lines
97 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 Freigabe-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.' },
/* AUS 'REVIEW' WURDE 'ZUR FREIGABE' (20.09.2026).
Weiter unten stand die Begruendung schon selbst da: 'Review'
allein sagt einem Neuen nichts. Es ist englisch, es ist ein
Hauptwort ohne Handlung, und es verraet nicht, WER jetzt dran
ist. 'Zur Freigabe' sagt beides: fertig von mir, wartet auf
jemanden. Der Schluessel bleibt `review` -- der gehoert der
Datenbank, nicht dem Menschen. */
{ status: 'review', name: 'Zur Freigabe', unter: 'Wartet darauf, dass jemand draufschaut',
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: 'Zur Freigabe', 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 (!await frageNach({
titel: 'Rückmeldung entfernen?',
was: 'Sie verschwindet aus der Aufgabe.',
bleibt: 'Die Aufgabe selbst bleibt unverändert stehen.',
endgueltig: true, ja: '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(sagWas((await r.json().catch(() => ({}))).fehler, 'Ging nicht.')); return; }
await laden();
} catch { /* umgeleitet */ } finally { senden.disabled = false; }
});
box.append(form);
feld.focus();
}
/* =====================================================================
EINE KARTE IST EIN STUECK ARBEIT -- NICHT EINE TABELLENZEILE
(23.09.2026)
Filipe: "so wie alles gerade ist ist es zu wenig und zu viel zu
gleich."
Das ist genau richtig beobachtet, und die Zahlen dahinter sind
diese (am 23.09.2026 in der echten Datenbank nachgemessen):
115 offene Aufgaben -- aber nur 17 VERSCHIEDENE
vier Modis, jede Aufgabe an jede verteilt -> x4
und die Verteilung lief ZWEIMAL -> x8
Das Brett zeigte daraufhin 136 Karten in EINER Spalte, waehrend
drei Spalten leer danebenstanden. Gemessen war die Seite 46 077 px
hoch bei 950 px Bildschirm -- 48 Bildschirme Scrollen. Am Handy 53.
Das ist das "zu viel". Das "zu wenig" ist dasselbe von der anderen
Seite: Nirgends stand, WAS eigentlich zu tun ist. Man musste 136
Karten lesen, um 17 Dinge zu erfahren.
WAS SICH AENDERT: Aufgaben mit demselben Titel, derselben Frist und
derselben Kategorie sind EIN Stueck Arbeit, an mehrere verteilt.
Sie bekommen EINE Karte mit den Leuten darauf. Aus 136 Karten
werden 17.
WARUM DAS NICHT NUR ZUSAMMENFASSEN IST: Auf der Sammelkarte sieht
man etwas, das vorher NICHT sichtbar war -- naemlich wie weit die
Gruppe ist ("0 von 4") und wer noch nicht angefangen hat. Vorher
musste man vier Karten suchen, die 30 Karten auseinanderlagen.
UND SIE ZEIGT DIE DOPPELTEN. Steht jemand zweimal in derselben
Gruppe, ist die Aufgabe doppelt verteilt worden. Das sieht man
jetzt auf einen Blick statt gar nicht.
WER ALLEIN AN ETWAS SITZT, bekommt unveraendert seine Karte --
eine Gruppe aus einem Menschen ist einfach eine Karte. Der
Sonderfall ist damit kein Sonderfall, sondern der Normalfall mit
der Zahl 1.
===================================================================== */
/* Wie weit ein Status ist. Die Gruppe steht dort, wo ihr am
WENIGSTEN fortgeschrittenes Mitglied steht -- denn das ist die
Arbeit, die noch zu tun ist. Wuerde die Gruppe bei "erledigt"
stehen, sobald EINE Person fertig ist, verschwaende sie aus dem
Blick, obwohl drei noch nicht angefangen haben. */
const RANG = { offen: 0, arbeit: 1, review: 2, erledigt: 3 };
/** Aus einer Liste Aufgaben werden Stuecke Arbeit. */
function gruppieren(liste) {
const nach = new Map();
for (const a of liste) {
/* DER SCHLUESSEL IST DAS, WAS EIN MENSCH SIEHT: derselbe Titel,
dieselbe Frist, dieselbe Kategorie. Nicht das Vorlagenfeld --
das ist bei beiden Verteilrunden gleich und wuerde die
Doppelten unsichtbar machen, statt sie zu zeigen.
\u0000 als Trenner, weil es in keinem Titel vorkommt; ein
Bindestrich koennte selbst im Titel stehen. */
const schluessel = [a.titel, a.frist || '', a.kategorie || ''].join('\u0000');
let g = nach.get(schluessel);
if (!g) { g = { schluessel, alle: [] }; nach.set(schluessel, g); }
g.alle.push(a);
}
for (const g of nach.values()) {
/* Die Karte zeigt die Angaben der am wenigsten fortgeschrittenen
Aufgabe -- dieselbe, die auch die Spalte bestimmt. */
g.alle.sort((x, y) => (RANG[x.status] ?? 0) - (RANG[y.status] ?? 0) || x.id - y.id);
g.erste = g.alle[0];
g.status = g.erste.status;
g.fertig = g.alle.filter((a) => a.status === 'erledigt').length;
/* Wer ist beteiligt -- und wer steht doppelt drin? */
const leute = new Map();
for (const a of g.alle) {
const kennung = a.verantwortlich_id != null
? 'p' + a.verantwortlich_id
: 'x' + (a.verantwortlich_name || '?');
if (!leute.has(kennung)) {
leute.set(kennung, { name: a.verantwortlich_name || 'Niemand',
id: a.verantwortlich_id ?? null, aufgaben: [] });
}
leute.get(kennung).aufgaben.push(a);
}
g.leute = [...leute.values()].sort((x, y) => x.name.localeCompare(y.name, 'de'));
g.doppelt = g.leute.filter((p) => p.aufgaben.length > 1);
}
return [...nach.values()];
}
function karte(a) {
const k = el('article', 'karte');
k.dataset.prio = a.prioritaet;
/* DIE NUMMER AN DER KARTE. Ohne sie kann kein Verweis auf eine
EINZELNE Aufgabe zeigen -- und drei davon gibt es im Haus. */
k.dataset.id = String(a.id);
const kopf = el('div', 'karte__kopf');
kopf.append(el('h3', 'karte__titel', a.titel));
const stift = el('button', 'karte__stift', 'bearbeiten');
stift.type = 'button';
stift.setAttribute('aria-label', `${a.titel} bearbeiten`);
stift.addEventListener('click', () => dialogOeffnen(a));
kopf.append(stift);
k.append(kopf);
/* DIE BESCHREIBUNG DARF GESTALTET SEIN (21.09.2026).
Filipe: "... sei es wenn die modis oder rechte hand oder
dogfather eine aufgabe oder sachen posten."
Dieselbe Quelle wie im Chat und auf den Brettern. `textContent`
bleibt der Weg -- `Textform.zeichne` baut Elemente, kein HTML. */
if (a.beschreibung) {
const bt = el('p', 'karte__text');
if (window.Textform) bt.append(window.Textform.zeichne(a.beschreibung));
else bt.textContent = a.beschreibung;
k.append(bt);
}
const zeile = el('div', 'karte__zeile');
/* WICHTIG STEHT DA, statt nur eine Kante einzufaerben.
Die Prioritaet gab es als 3 px breiten Rand links -- direkt neben
der farbigen Kante der Spalte, und damit praktisch unsichtbar.
Als Wort steht sie da, wo man sie liest.
NUR bei "hoch". Eine Marke an jeder Karte waere keine Marke mehr:
Was immer da steht, sieht man nach zwei Tagen nicht mehr. */
if (a.prioritaet === 'hoch' && a.status !== 'erledigt') {
zeile.append(el('span', 'karte__wichtig', 'wichtig'));
}
/* ---- WIE LANGE GEHT DAS SCHON SO? (15.09.2026, Stufe 9) --------
"Ueberfaellig" war ein Schalter: an oder aus. Eine Karte, deren
Frist gestern lief, sah aus wie eine, die seit drei Wochen liegt.
Stehen auf einem Brett zwanzig Karten "ueberfaellig", sagt keine
davon mehr etwas -- man ueberliest sie alle, auch die, die seit
einem Monat blockiert.
DAS WORT KOMMT VOM SERVER. Eine zweite Rechnung hier waere die,
die irgendwann anders antwortet als die Startseite. */
if (a.eskalation) {
const e = el('span', 'karte__eskal', a.eskalation.wort);
e.dataset.stufe = a.eskalation.stufe;
e.title = a.eskalation.grund === 'frist'
? 'Die Frist ist vorbei. Die Zahl sagt, seit wann.'
: 'Ohne Frist – seit so vielen Tagen hat niemand die Karte angefasst.';
zeile.append(e);
}
if (a.frist) {
/* Das Wort "ueberfaellig" steht jetzt oben in der Stufe. Hier
bleibt nur noch das Datum -- zweimal dasselbe nebeneinander
waere Laerm. */
const faellig = !!a.eskalation && a.eskalation.grund === 'frist';
const f = el('span', 'karte__frist' + (faellig ? ' karte__frist--faellig' : ''),
'bis ' + datumHuebsch(a.frist));
zeile.append(f);
}
/* Die Kategorie als Wort in derselben Zeile wie Frist und Person.
Kein eigener Streifen und keine eigene Farbe: Sie ist eine
Einordnung, keine Warnung -- und eine Karte, auf der alles
hervorsticht, hebt nichts hervor. Der Name kommt aus der Liste
des Servers; steht die Aufgabe in einer Kategorie, die es nicht
mehr gibt, wird nichts angezeigt statt eines Schluessels.
KEINE EIGENE CSS-KLASSE. Sie muesste in sieben gleichlautenden
Kopien der Modulliste gepflegt werden (pruef-css-klassen wacht
darueber, dass die sieben Zeichen fuer Zeichen gleich sind) --
viel Aufwand und sieben Gelegenheiten fuer einen Unterschied,
fuer eine Zeile Text. Dieselbe schlichte Form wie "· Bereich X"
daneben tut es. */
/* DER KANAL STEHT VOR DER KATEGORIE. Er beantwortet die groebere
Frage -- WO ueberhaupt -- und die muss man zuerst wissen, um die
feinere zu verstehen. */
if (a.kanal) {
const kName = kanaele.find((k) => k.wert === a.kanal)?.name;
if (kName) {
const z = el('span', 'aufgabe__kanal', kName);
z.dataset.kanal = a.kanal;
zeile.append(z);
}
}
if (a.kategorie) {
const name = kategorien.find((k) => k.wert === a.kategorie)?.name;
if (name) zeile.append(el('span', null, '· ' + name));
}
if (a.verantwortlich_name) zeile.append(el('span', null, '· ' + a.verantwortlich_name));
/* WER VERTEILT, DARF AUCH SEHEN, WOHIN (21.09.2026). Dieselbe
Frage wie bei den Feldern, also dieselbe Antwort -- und nicht
eine Rollenmenge, die daneben altert. */
/* DER SCHALTER „DAUERHAFT" NUR FUER DIE, DIE VERTEILEN
(30.09.2026). Ein Modi legt sich Aufgaben selbst an -- eine,
die er danach nicht mehr abhaken kann, waere eine Falle, die er
sich selbst stellt. Die Schranke sitzt im Server; das hier ist
die Hoeflichkeit davor. */
var dfeld = document.getElementById('feld-dauerhaft');
if (dfeld) dfeld.hidden = !(ich.darf_verteilen ?? LEITUNG.has(ich.rolle));
if ((ich.darf_verteilen ?? 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');
/* NUR AN KARTEN, DIE MAN AUCH BEWEGEN DARF (20.09.2026).
Vorher standen diese zwei Knoepfe an JEDER Karte -- auch an
fremden. Ein Modi sieht das Brett des ganzen Teams; er tippte
an einer fremden Karte auf „In Arbeit", der Server lehnte mit
403 ab, und die Meldung erschien ganz oben auf der Seite. Bei
einer Karte weiter unten sah er nichts: ein Knopf, der einfach
nichts tut.
`darf_aendern` kommt vom Server -- aus derselben Regel, die
auch das Schreiben entscheidet. */
if (a.darf_aendern !== false) {
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);
/* WER HAT SIE, UND WIE STEHT SIE BEI IHM (21.09.2026).
Der Block kommt aus zuteilung.js und nicht von hier: Diese Datei
hat 1900 Zeilen, und alles zur Zuteilung gehoert an EINE Stelle.
Er gibt null zurueck, wenn niemandem etwas zugeteilt ist -- dann
sieht die Karte aus wie vorher. */
if (window.Zuteilung) {
const zb = window.Zuteilung.karteBlock(a, ich, () => laden());
if (zb) k.append(zb);
}
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 = sagWas(d.fehler, 'Hat nicht geklappt.');
return;
}
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
dlg.vergessen?.();
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(sagWas(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: 'zur Freigabe ▶', 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 */
/* DIE LEUTE ALS DATEN, nicht nur als <option>. Bis zum 23.09.2026
stand diese Zeile im Vorlagenblock; der ist ausgezogen, die
Zuweisung weiter unten blieb. Ohne Deklaration waere daraus eine
stille globale Variable geworden -- "node --check" sieht so etwas
nicht, und im Browser faellt es erst auf, wenn zwei Seiten sich
gegenseitig die Liste ueberschreiben. */
let personenListe = [];
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. */
/* AUF DER TEAM-ADRESSE HEISST DAS NICHT „CREATOR" (24.09.2026).
Filipe: „in dieser app gibt es keinen und wird es niemals einen
anderen creator geben wie mich."
Er hat recht, und es stand woertlich auf dem Bildschirm: Die
Reihe war mit „Alle Creator" beschriftet, und darunter standen
Modis. Dort gibt es keine Creator -- die Rollen auf crew. sind
rechte Hand, linke Hand und Modi.
`ich.haus` kommt vom Server (siehe /api/ich). Eine Rollenliste
hier waere die zweite Wahrheit und ausserdem falsch fuer
DogFather, der in beiden Haeusern arbeitet. */
const imTeam = ich.haus === 'crew';
ziel.append(el('span', 'creator-reihe__schild',
imTeam
? 'Für wen'
: (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);
}
/* ==== DIE BEWERBUNGEN DER MODIS (25.09.2026) ====================
Filipe: „ich will in dieser seite auch eine eigene kategorie fuer
die aufgaben wo die modis sich selbst bewerben. ich will dass man
die getrennt sieht. mach es richtig uebersichtlich."
SIE STANDEN BISHER NUR AUF DEM VORLAGENBRETT, an der jeweiligen
Karte. Das ist der richtige Ort zum ENTSCHEIDEN -- man liest die
Aufgabe und den Namen nebeneinander. Es ist aber der falsche Ort
zum SEHEN: Wer morgens das Aufgabenbrett oeffnet, sieht nicht,
dass drei Leute auf eine Antwort warten, und das Vorlagenbrett
steht eine Seite weiter.
DER STAND WIRD GEMERKT, nicht bei jedem Zeichnen neu geholt: Das
Brett zeichnet sich bei jedem Statuswechsel neu, und ein Abruf je
Klick waere Last ohne Gegenwert. Geholt wird beim Laden und nach
jeder Entscheidung -- also genau dann, wenn sich etwas aendert. */
let bewerbungen = [];
/* OB ICH ENTSCHEIDEN DARF, SAGT DER SERVER -- nicht der Rollenname.
Bis die Antwort da ist, false: Lieber einen Knopf zu spaet zeigen
als einen, der eine Absage holt. */
let darfEntscheiden = false;
async function bewerbungenHolen() {
try {
const a = await hole('/workspace/api/vorlagen/bewerbungen');
if (!a.ok) return;
const d = await a.json();
bewerbungen = d.bewerbungen || [];
darfEntscheiden = d.darf_entscheiden === true;
} catch { /* hole() hat schon gemeldet */ }
}
function zeichne(aufgaben) {
const brett = $('brett');
brett.textContent = '';
brett.setAttribute('aria-busy', 'false');
const gruppen = gruppieren(aufgaben);
/* ---- WO DAS VORLAGENBRETT STEHT, HAENGT AM BRETT --------------
Im HTML steht es UEBER dem Brett, und die Begruendung dort ist
richtig: "Wer ein leeres Brett hat, soll nicht erst daran
vorbeiscrollen, um zu sehen, womit man anfaengt."
Sie gilt nur fuer ein LEERES Brett. Liegen siebzehn Sachen da,
ist der Vorlagenkasten das, woran man vorbeiscrollt -- gemessen
114 px am Handy, vor der ersten Aufgabe.
Also keine feste Wahl, sondern eine Regel: oben, solange nichts
daliegt; darunter, sobald etwas daliegt. Die urspruengliche
Absicht bleibt damit erhalten, und der volle Fall wird besser. */
const vorlagen = $('vorlagenbrett');
if (vorlagen && vorlagen.parentElement) {
const dahinter = gruppen.length ? brett.nextSibling : brett;
if (vorlagen.nextSibling !== dahinter || gruppen.length === 0) {
vorlagen.parentElement.insertBefore(vorlagen,
gruppen.length ? brett.nextSibling : brett);
}
}
/* ---- EIN BAND, WENN ETWAS DOPPELT VERTEILT WURDE --------------
Am 22.09.2026 hat Ghost dieselbe Vorlage zweimal verteilt --
nachgemessen zwischen 16:05:24 und 16:05:53, also innerhalb von
29 Sekunden. Zweimal gedrueckt, weil beim ersten Mal scheinbar
nichts passierte. Ergebnis: 56 Aufgabenpaare, die niemand
auseinanderhalten kann.
DAS BAND SAGT ES EINMAL, MIT ZAHL. Nicht siebzehnmal an den
Karten, und nicht gar nicht. Es verschwindet von selbst, sobald
nichts mehr doppelt ist -- ein Hinweis, den man wegklicken
muesste, kommt beim naechsten Laden wieder. */
const doppelPaare = gruppen.reduce((n, g) =>
n + g.leute.reduce((m, p) => m + Math.max(0, p.aufgaben.length - 1), 0), 0);
if (doppelPaare > 0) {
const band = el('div', 'doppelband');
band.setAttribute('role', 'status');
band.append(el('span', 'doppelband__zahl', String(doppelPaare)));
const text = el('div', 'doppelband__text');
text.append(el('strong', null, doppelPaare === 1
? 'Eine Aufgabe steht doppelt im Brett.'
: doppelPaare + ' Aufgaben stehen doppelt im Brett.'));
text.append(el('span', null,
'Dieselbe Vorlage wurde zweimal verteilt. An den Karten steht '
+ '×2 bei den Leuten, die sie doppelt haben.'));
band.append(text);
brett.parentElement.insertBefore(band, brett);
}
/* ==== DIE SPALTE STEHT GANZ VORN ==============================
Sie ist die einzige, in der jemand auf eine ANTWORT wartet --
alle anderen zeigen Arbeit, die laeuft. Was auf eine Antwort
wartet, gehoert an die Stelle, die man zuerst ansieht.
SIE VERSCHWINDET, WENN NICHTS WARTET. Eine leere Spalte
„Bewerbungen" stuende 360 Tage im Jahr im Weg, um an fuenf
Tagen etwas zu sagen -- anders als die vier festen Spalten, die
auch leer eine Aussage haben („hier landet, was fertig ist").
DER MODI SIEHT SEINE EIGENE und nur die: Das entscheidet der
Server (`vorlagenBewerbungenFuer`), nicht diese Datei. Fuer ihn
ist sie die Antwort auf „ist mein Wunsch angekommen?" -- vorher
gab es darauf nirgends eine. */
if (bewerbungen.length) {
const spalte = el('section', 'spalte spalte--bewerbung');
spalte.dataset.status = 'beworben';
spalte.dataset.leer = 'nein';
spalte.style.setProperty('--wuchs', String(Math.min(bewerbungen.length, 6)));
const kopf = el('h2', 'spalte__kopf');
kopf.append(el('span', 'spalte__punkt'), el('span', null, 'Bewerbungen'),
el('span', 'spalte__zahl', String(bewerbungen.length)));
spalte.append(kopf);
/* KEIN ROLLENNAME HIER (25.09.2026, gefunden von
pruef-modi-wortleck -- am Tag nach dem Einbau).
Hier stand „Modis möchten das übernehmen". Diese Datei geht an
JEDEN, der die Seite öffnet: an jeden Manager, jeden Scout,
jeden Creator im anderen Haus. Der verborgene Zugang hält
genau so lange, wie der Rollenname nicht im ausgelieferten
Quelltext steht -- und ein Satz, den man auf dem Bildschirm
gar nicht sieht (er steht nur, wenn es Bewerbungen gibt),
steht trotzdem in der Datei.
„JEMAND" SAGT DASSELBE. Wer es ist, steht ohnehin auf den
Karten darunter -- mit Namen, und die bekommt nur zu sehen,
wer sie sehen darf. */
spalte.append(el('p', 'spalte__unter', darfEntscheiden
? 'Jemand möchte das übernehmen – du entscheidest'
: 'Du hast dich beworben – DogFather oder die rechte Hand antworten'));
const kasten = el('div', 'spalte__karten');
for (const b of bewerbungen) kasten.append(bewerbungsKarte(b));
spalte.append(kasten);
brett.append(spalte);
}
for (const s of SPALTEN) {
const eigene = gruppen.filter((g) => g.status === s.status);
/* ZWEI ZAHLEN, UND SIE SAGEN VERSCHIEDENES: wie viele STUECKE
ARBEIT hier liegen -- und wie viele Aufgaben daraus geworden
sind. Nur die grosse Zahl zu zeigen war das "zu viel"; nur die
kleine waere das "zu wenig". */
const stueck = eigene.reduce((n, g) => n + g.alle.length, 0);
const spalte = el('section', 'spalte');
spalte.dataset.status = s.status;
/* LEERE SPALTEN SCHRUMPFEN. Drei leere Spalten nahmen drei
Viertel des Bildschirms ein, um dreimal "nichts" zu sagen.
Sie verschwinden nicht -- eine Spalte, die fehlt, kann man
auch nicht fuellen -- aber sie treten zur Seite. */
spalte.dataset.leer = eigene.length ? 'nein' : 'ja';
/* WIE VIEL PLATZ DIESE SPALTE BEKOMMT, haengt daran, wie viel
sie traegt.
Bis eben bekam jede nicht-leere Spalte gleich viel: "Offen"
mit siebzehn Karten war genauso breit wie "Erledigt" mit
einer -- beide 369 px, gemessen. Die eine lief dadurch
5 600 px nach unten, waehrend neben der anderen Platz frei
blieb.
GEDECKELT BEI SECHS. Ohne Deckel bekaeme eine Spalte mit 100
Karten das Hundertfache und alle anderen einen Faden. Sechs
ist der Punkt, ab dem mehr Breite nichts mehr bringt, weil
ohnehin nur so viele Karten nebeneinander passen. */
spalte.style.setProperty('--wuchs', String(Math.min(Math.max(eigene.length, 1), 6)));
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);
spalte.append(el('p', 'spalte__unter', s.unter));
/* Die kleine Zeile darunter nur, wenn zusammengefasst wurde --
sonst stuende dort "17 Aufgaben" neben der 17 darueber. */
if (stueck > eigene.length) {
spalte.append(el('p', 'spalte__summe',
stueck + ' Aufgaben, an mehrere verteilt'));
}
if (!eigene.length) spalte.append(el('p', 'spalte__leer', s.leer));
else {
/* EIN EIGENER KASTEN UM DIE KARTEN.
Er tut nichts ausser: Er erlaubt dem CSS, die Karten bei
viel Platz NEBENEINANDER zu legen statt untereinander.
Gemessen am 23.09.2026: Bei 1600 px Breite stand die volle
Spalte 350 px breit da und lief 5 776 px nach unten,
waehrend rechts daneben 800 px leer blieben.
WARUM EIN RASTER UND NICHT columns: Mehrspaltiger
Textfluss zerschneidet Elemente mitten durch -- am
19.09.2026 hat genau das auf der Befinden-Seite die
Fragegruppen halbiert, und "break-inside: avoid" hatte
still aufgehoert zu wirken. Ein Raster legt ganze Kacheln
nebeneinander und kann gar nicht schneiden. */
const kasten = el('div', 'spalte__karten');
for (const g of eigene) {
const k = g.alle.length === 1 ? karte(g.erste) : sammelKarte(g);
k.dataset.status = g.status;
const a = g.erste;
if (g.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. */
if (g.status !== 'erledigt' && a.frist === heute) k.dataset.heute = 'ja';
kasten.append(k);
}
spalte.append(kasten);
}
brett.append(spalte);
}
}
/** Eine Karte fuer eine Bewerbung -- wer, worauf, und was er dazu
* geschrieben hat.
*
* SIE SIEHT AUS WIE DIE ANDEREN KARTEN und ist doch eine andere
* Sache: Es gibt sie noch nicht als Aufgabe. Deshalb dieselbe Form
* (man soll das Brett lesen koennen, ohne umzulernen) und ein
* eigenes Merkmal `data-art="bewerbung"` fuer die Farbe.
*
* DIE ZWEI KNOEPFE STEHEN NUR BEI DENEN, DIE ENTSCHEIDEN. Der Modi
* sieht stattdessen einen Satz: Sein Wunsch ist angekommen. Ein
* Knopf, der eine Absage holt, ist schlimmer als keiner.
*/
function bewerbungsKarte(b) {
const k = el('article', 'karte karte--bewerbung');
k.dataset.art = 'bewerbung';
const kopf = el('div', 'karte__kopf');
kopf.append(el('h3', 'karte__titel', b.titel));
k.append(kopf);
/* WER ES SEIN MOECHTE steht gross, nicht klein: Ueber die Person
wird hier entschieden, nicht ueber den Titel. Den kennt man
vom Vorlagenbrett. */
const wer = el('p', 'karte__wer');
wer.append(el('strong', null, b.selbst ? 'Du' : b.person_name));
wer.append(document.createTextNode(b.selbst
? ' hast dich beworben' : ' möchte das übernehmen'));
k.append(wer);
if (b.text) {
/* SEIN SATZ DAZU, als Zitat gesetzt -- er gehoert ihm, nicht
dem Brett. Ohne ihn entscheidet man ueber einen Namen. */
const zitat = el('p', 'karte__bwort');
zitat.textContent = b.text;
k.append(zitat);
}
const fuss = el('div', 'karte__fuss');
if (darfEntscheiden) {
const ja = el('button', 'knopf knopf--klein', 'Annehmen');
ja.type = 'button';
ja.addEventListener('click', () => bewerbungEntscheiden(ja, b, true));
const nein = el('button', 'knopf knopf--klein karte__ablehnen', 'Ablehnen');
nein.type = 'button';
nein.addEventListener('click', () => bewerbungEntscheiden(nein, b, false));
fuss.append(ja, nein);
} else {
fuss.append(el('span', 'karte__warte', 'wartet auf Antwort'));
}
k.append(fuss);
return k;
}
/** Annehmen oder ablehnen -- mit der Moeglichkeit, ein Wort
* dazuzuschreiben.
*
* DIE NOTIZ IST FREIWILLIG, aber sie wird GEFRAGT: Eine Absage ohne
* Grund ist die Sorte Antwort, nach der man im Chat nachfragt --
* und dann steht dieselbe Erklaerung doppelt. Abbrechen des
* Dialogs bricht die ganze Handlung ab; sonst haette man aus
* Versehen zugesagt, weil man das Fenster wegklicken wollte.
*/
async function bewerbungEntscheiden(knopf, b, ja) {
const wer = b.selbst ? 'dich' : b.person_name;
const antwort = await window.frageNach({
titel: ja ? `„${b.titel}“ an ${b.person_name}?` : `${b.person_name} absagen?`,
was: ja
? `Die Aufgabe wird angelegt und liegt danach bei ${b.person_name}.`
: `Die Vorlage bleibt auf dem Brett – ${wer} kann sich später wieder bewerben.`,
grund: 'Willst du etwas dazuschreiben? (freiwillig)',
grundPflicht: false,
platzhalter: ja ? 'z. B. super, mach das gern' : 'z. B. diesmal nicht, nächste Woche gern',
ja: ja ? 'Annehmen' : 'Ablehnen',
nein: 'Doch nicht',
});
/* `frageNach` gibt bei „Doch nicht" etwas Falsches zurueck --
dann ist NICHTS passiert. Das ist wichtiger als es klingt: Wer
das Fenster wegklickt, weil er sich vertan hat, darf damit
keine Zusage ausloesen. */
if (!antwort) return;
const wort = (antwort && antwort.grund) || '';
knopf.disabled = true;
try {
const a = await hole(`/workspace/api/vorlagen/bewerbung/${ja ? 'annehmen' : 'ablehnen'}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: b.id, kommentar: wort || undefined }),
});
if (!a.ok) { melde('Das ließ sich gerade nicht entscheiden.'); return; }
const antwort = await a.json().catch(() => ({}));
/* DIE LISTE KOMMT AUS DER ANTWORT. Sie im Browser
zusammenzustreichen waere eine zweite Wahrheit -- und bei
zwei offenen Fenstern die falsche. */
if (Array.isArray(antwort.bewerbungen)) bewerbungen = antwort.bewerbungen;
else await bewerbungenHolen();
await laden();
} catch { /* hole() hat schon gemeldet */ } finally {
knopf.disabled = false;
}
}
/** Eine Karte fuer ein Stueck Arbeit, an dem mehrere sitzen. */
function sammelKarte(g) {
const a = g.erste;
const k = el('article', 'karte karte--sammel');
k.dataset.prio = a.prioritaet;
k.dataset.id = String(a.id);
/* ALLE NUMMERN STEHEN AN DER KARTE. Der Personenfilter und der
Sprung von der Startseite suchen ueber die Nummer; mit nur
EINER wuerden sie die Karte uebersehen, sobald die gesuchte
Aufgabe die zweite der Gruppe ist. */
k.dataset.ids = g.alle.map((x) => x.id).join(',');
const kopf = el('div', 'karte__kopf');
kopf.append(el('h3', 'karte__titel', a.titel));
/* WIE WEIT SIND WIR -- als Zahl, nicht als Gefuehl. */
const stand = el('span', 'sammel__stand', g.fertig + ' von ' + g.alle.length);
stand.dataset.fertig = g.fertig === g.alle.length ? 'ja' : 'nein';
kopf.append(stand);
k.append(kopf);
/* Der Balken sagt dasselbe noch einmal fuer das Auge. Farbe ALLEIN
traegt nichts -- deshalb steht die Zahl daneben und nicht statt
ihrer. */
const balken = el('div', 'sammel__balken');
balken.setAttribute('role', 'img');
balken.setAttribute('aria-label', g.fertig + ' von ' + g.alle.length + ' erledigt');
const fuell = el('span');
fuell.style.setProperty('--anteil', String(Math.round(g.fertig / g.alle.length * 100)));
balken.append(fuell);
k.append(balken);
/* DIE BESCHREIBUNG STEHT NICHT AUF DER KARTE, SONDERN DAHINTER.
Auf einem Brett sucht man: was, wer, wie weit, bis wann. Der
Titel beantwortet das "was" -- "Chat moderieren und neue
Zuschauer begruessen" braucht keine Erlaeuterung, um erkannt zu
werden. Die Beschreibung ist die Feinheit fuer den, der die
Aufgabe UEBERNIMMT, nicht fuer den, der das Brett ueberfliegt.
Gemessen war sie vier bis sechs Zeilen je Karte. Bei siebzehn
Karten sind das ueber achthundert Pixel fuer etwas, das man
beim Suchen ueberspringt.
SIE IST NICHT WEG -- ein Knopf, und sie steht da. Ein Knopf,
der nur erscheint, wenn es auch etwas zu lesen gibt. */
/* KEIN EIGENER KNOPF FUER DIE BESCHREIBUNG.
Es stand hier einer, und er war gemessen 44 px hoch -- die
Hausregel fuer etwas, das man mit dem Finger trifft. Bei
siebzehn Karten sind das 750 px fuer eine Moeglichkeit, die man
selten braucht: Auf einem Brett sucht man, man liest nicht.
DIE BESCHREIBUNG IST TROTZDEM DA. Jeder Name auf der Karte
oeffnet genau die Aufgabe dieser Person -- und dort steht sie,
vollstaendig und aenderbar. Ein Weg statt zwei, und der
vorhandene ist der bessere: Man will ohnehin meist etwas TUN,
nicht nur nachlesen.
Auf der EINZELkarte bleibt sie stehen. Dort ist sie kein
Vielfaches, sondern ein Text. */
const zeile = el('div', 'karte__zeile');
if (a.prioritaet === 'hoch' && g.status !== 'erledigt') {
zeile.append(el('span', 'karte__wichtig', 'wichtig'));
}
if (a.eskalation) {
const e = el('span', 'karte__eskal', a.eskalation.wort);
e.dataset.stufe = a.eskalation.stufe;
zeile.append(e);
}
/* Frist, Kanal und Kategorie genau wie auf der Einzelkarte --
dieselben Hilfsmittel, dieselben Klassen. Zwei Schreibweisen
fuer dieselbe Angabe waeren zwei Stellen, an denen sie sich
auseinanderentwickeln. */
if (a.frist) {
const faellig = !!a.eskalation && a.eskalation.grund === 'frist';
zeile.append(el('span', 'karte__frist' + (faellig ? ' karte__frist--faellig' : ''),
'bis ' + datumHuebsch(a.frist)));
}
if (a.kanal) {
const kName = kanaele.find((x) => x.wert === a.kanal)?.name;
if (kName) {
const z = el('span', 'aufgabe__kanal', kName);
z.dataset.kanal = a.kanal;
zeile.append(z);
}
}
if (a.kategorie) {
const name = kategorien.find((x) => x.wert === a.kategorie)?.name;
if (name) zeile.append(el('span', null, '· ' + name));
}
if (zeile.childElementCount) k.append(zeile);
/* ---- Die Leute. Jeder Name traegt seinen eigenen Stand. ------- */
const reihe = el('div', 'sammel__leute');
reihe.setAttribute('role', 'group');
reihe.setAttribute('aria-label', 'Wer daran sitzt');
for (const p of g.leute) {
/* Der Stand dieser Person ist der ihrer am wenigsten
fortgeschrittenen Aufgabe -- dieselbe Regel wie fuer die
Gruppe, damit auf der Karte nicht zwei Rechnungen stehen. */
const seine = [...p.aufgaben].sort((x, y) => (RANG[x.status] ?? 0) - (RANG[y.status] ?? 0));
const b = el('button', 'sammel__person');
b.type = 'button';
b.dataset.status = seine[0].status;
/* ZWEI SCHREIBWEISEN DESSELBEN NAMENS -- der volle und der
kurze. Welche zu sehen ist, entscheidet das CSS nach der
Breite; der Browser entscheidet das besser als eine Zahl in
JavaScript, die beim naechsten Geraet nicht mehr stimmt.
Gemessen: Vier Namen brauchten am Handy zwei Reihen (74 px),
vier Buchstaben brauchen eine (38 px). Der volle Name bleibt
im aria-label und im title -- wer ihn hoeren oder lesen will,
bekommt ihn. */
b.append(el('span', 'sammel__punkt'));
b.append(el('span', 'sammel__voll', p.name));
b.append(el('span', 'sammel__kurz', (p.name.trim()[0] || '?').toUpperCase()));
if (p.aufgaben.length > 1) {
/* DOPPELT VERTEILT. Das steht AN der Person und nicht nur im
Satz darunter -- man sucht sonst, welche der vier gemeint
ist. */
b.append(el('span', 'sammel__mal', '\u00d7' + p.aufgaben.length));
b.dataset.doppelt = 'ja';
}
const wort = { offen: 'noch nicht angefangen', arbeit: 'arbeitet daran',
review: 'wartet auf Freigabe', erledigt: 'fertig' }[seine[0].status] || seine[0].status;
b.setAttribute('aria-label', p.name + ': ' + wort
+ (p.aufgaben.length > 1 ? ', steht ' + p.aufgaben.length + '-mal drin' : ''));
b.title = b.getAttribute('aria-label');
b.addEventListener('click', () => dialogOeffnen(seine[0]));
reihe.append(b);
}
k.append(reihe);
/* DER SATZ STEHT NICHT MEHR AN JEDER KARTE.
Beim ersten Bauen stand er hier -- und damit siebzehnmal
untereinander, wortgleich. Ein Hinweis, der auf jeder Karte
steht, ist kein Hinweis mehr, sondern Tapete; genau die Lehre
vom 03.09.2026 ("eine Warnung, die immer kommt, ist keine
Warnung"). Ausserdem kostete er zwei Zeilen je Karte.
Was an der Karte BLEIBT, ist das "x2" an der betroffenen
Person -- das ist die Auskunft, die man an DIESER Stelle
braucht. Der Satz dazu steht einmal ueber dem Brett, mit der
Gesamtzahl, wo man auch etwas dagegen tun kann. */
return k;
}
/* ---------- Sprung vom Bericht -------------------------------------------
"wenn man auf die Kisten drueckt, sofort zu diesem Punkt
weitergeschickt werden" (01.09.2026).
Hier wird NICHT gefiltert, sondern hervorgehoben. Das Brett lebt
davon, dass man alle vier Spalten nebeneinander sieht -- wer nur die
ueberfaelligen zeigt, nimmt genau den Ueberblick weg, wegen dem man
hergekommen ist. */
/* ZWEI SCHLUESSEL KAMEN AM 09.09.2026 DAZU (screen1).
Filipe, mit Bild der sieben Zahlenkarten auf der Startseite: "wenn
ich auf die druecke soll ich auf zu denen punkten gebracht werden."
Fuenf der sieben konnten das schon (offen, arbeit, review,
erledigt, ueberfaellig) -- nur wusste es niemand, weil die Karten
keine Links waren. "Heute faellig" und "Abgebrochen" fehlten
ganz. */
const ZEIG_TEXT = {
offen: 'Aufgaben, die offen sind',
arbeit: 'Aufgaben, die gerade laufen',
review: 'Aufgaben, die auf Freigabe warten',
erledigt: 'Aufgaben, die erledigt sind',
ueberfaellig: 'Aufgaben, deren Frist vorbei ist',
heute: 'Aufgaben, die heute fällig sind',
abgebrochen: 'Aufgaben, die abgebrochen wurden',
};
function sprungZeigen() {
/* EINE EINZELNE AUFGABE, ueber `#a<nummer>` in der Adresse.
Sie steht VOR der Auswertung von `?zeigen=`, weil sie genauer
ist: Wer eine bestimmte Aufgabe meint, meint nicht ihre Spalte.
Die Nummer kommt aus dem Anker und nicht aus `?zeigen=`, weil
das Brett von drei Stellen aus so verlinkt wird -- und die
Schreibweise war schon da, nur gelesen hat sie niemand. */
const einzeln = /^#a(\d+)$/.exec(location.hash || "");
if (einzeln) {
const k = document.querySelector(`.karte[data-id="${einzeln[1]}"]`);
window.zielMarkieren?.(k ? [k] : [], 'Diese eine Aufgabe');
return;
}
const was = window.zielSchluessel?.();
if (!was || !ZEIG_TEXT[was]) return;
/* ABGEBROCHENE STEHEN NICHT IM BRETT, sondern in einem eigenen
Kasten weiter unten -- und der ist zugeklappt, solange niemand
ihn aufmacht. Ein Sprung dorthin muss ihn also erst OEFFNEN,
sonst landet man an einer Stelle, an der nichts zu sehen ist.
Genau die Sorte Sprung, die einen glauben laesst, der Knopf sei
kaputt. */
if (was === 'abgebrochen') {
/* `#abgebrochen` IST das <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 = [];
let kanaele = [];
/* FUER WESSEN AUFGABEN die Kategorie gilt. Kommt fertig vom Server:
null -> fuer alle eigenen (das Feld steht immer da)
[Nummern] -> nur wenn eine dieser Personen gewaehlt ist
[] -> nie
ALS NUMMERN UND NICHT ALS ROLLENNAME, und das ist kein Zufall: Beim
ersten Anlauf stand hier ein Vergleich auf einen Rollennamen -- in
einer Datei, die JEDER bekommt, der die Seite oeffnet. Aus Nummern
laesst sich nichts schliessen; wer keine bekommt, sieht eine leere
Liste, und eine leere Liste sagt nichts. */
let kategorieFuer = [];
function kategorienEinsetzen() {
if (!kategorien.length) return;
for (const ziel of ['f-kategorie', 'b-kategorie']) {
const feld = $(ziel);
if (!feld || feld.dataset.fertig) continue;
feld.dataset.fertig = 'ja';
for (const k of kategorien) {
const o = el('option', null, k.name);
o.value = k.wert;
feld.append(o);
}
}
}
/* Braucht diese Aufgabe eine Kategorie?
`null` heisst "immer" -- dann arbeitet die angemeldete Person
ausschliesslich an eigenen Aufgaben, und dort gilt sie unabhaengig
davon, wen sie eintraegt. Sonst entscheidet die gewaehlte Person.
Wer eine leere Liste bekommt, sieht das Feld nie. */
/* ---- DIE KANAELE (17.09.2026) --------------------------------------
Filipe: "ich hab ja auch 2 neben account, hasidog und dogfather
clips." Ohne diesen Wert ist "schneide drei Ausschnitte" bei drei
Accounts keine Aufgabe, sondern eine Frage.
EINFACHER ALS BEI DEN KATEGORIEN, und das ist Absicht: Die
Kategorie haengt daran, FUER WEN die Aufgabe ist (`kategorie_fuer`);
ein Kanal gilt fuer jede Aufgabe, die jemand aus dem Haus
anfasst. Zwei verschiedene Sichtbarkeitsregeln fuer zwei Felder,
die nebeneinander stehen, waeren eine Gelegenheit, die falsche zu
ziehen. Hier gibt es genau eine Frage: Darf diese Person
ueberhaupt Kanaele benutzen? */
function kanaeleEinsetzen() {
if (!kanaele.length) return;
for (const ziel of ['f-kanal', 'b-kanal']) {
const feld = $(ziel);
if (!feld) continue;
const vorher = feld.value;
/* "Für alle" ist kein Platzhalter, sondern eine Antwort -- vieles
ist nicht kanalgebunden (eine Absprache, ein Zugang, eine
Auswertung). Deshalb steht dort ein Wort und kein Strich. */
feld.textContent = '';
feld.append(el('option', null, 'Für alle'));
feld.firstChild.value = '';
for (const k of kanaele) {
const o = el('option', null, k.name);
o.value = k.wert;
if (k.text) o.title = k.text;
feld.append(o);
}
feld.value = vorher;
}
}
function kanalFeldZeigen(praefix) {
const huelle = $(praefix === 'f' ? 'feld-kanal' : 'b-feld-kanal');
if (!huelle) return;
huelle.hidden = !kanaele.length;
if (!kanaele.length && $(`${praefix}-kanal`)) $(`${praefix}-kanal`).value = '';
}
function kategorieNoetig(creatorId, verantId) {
if (kategorieFuer === null) return true;
if (!Array.isArray(kategorieFuer) || !kategorieFuer.length) return false;
return kategorieFuer.includes(Number(creatorId))
|| kategorieFuer.includes(Number(verantId));
}
function kategorieFeldZeigen(praefix) {
const huelle = $(praefix === 'f' ? 'feld-kategorie' : 'b-feld-kategorie');
if (!huelle) return;
const zeigen = kategorien.length
&& kategorieNoetig($(`${praefix}-creator`)?.value, $(`${praefix}-verant`)?.value);
huelle.hidden = !zeigen;
/* Verborgen heisst auch: nichts mitschicken. Ein Wert, der in einem
unsichtbaren Feld stehenbleibt, wandert sonst beim naechsten
Speichern mit -- und niemand sieht, woher er kommt. */
if (!zeigen && $(`${praefix}-kategorie`)) $(`${praefix}-kategorie`).value = '';
}
/* Die Aufwandsstufen kommen mit der Aufgabenliste. Hier
weitergereicht, damit die Auswahl im Dialog dieselbe Liste zeigt,
die der Server annimmt. */
function aufwandUebernehmen(daten) {
window.aufwandStufenSetzen?.(daten?.aufwand);
}
/* DAS BAND UEBER DEM BRETT (21.09.2026, Abschnitt 1).
Fuer einen Modi sein persoenliches Resuemee, fuer DogFather und
die Haende die Uebersicht ueber alle. WELCHES der beiden, weiss
zuteilung.js aus `darf_verteilen` -- diese Datei muss die Rollen
nicht kennen.
Es wird bei JEDEM Laden neu gebaut, nicht nur beim ersten: Wer
eine Aufgabe annimmt, will die Zahl oben sofort anders sehen.
Sonst stuende dort die Zahl von vorhin, und das ist schlimmer als
gar keine Zahl. */
let bandKasten = null;
async function bandZeigen() {
if (!window.Zuteilung || !ich) return;
const neu = await window.Zuteilung.bandBauen(ich);
if (!neu) return;
const brett = $('brett');
if (!brett || !brett.parentNode) return;
if (bandKasten && bandKasten.parentNode) bandKasten.replaceWith(neu);
else brett.parentNode.insertBefore(neu, brett);
bandKasten = neu;
/* EINE PERSON ANTIPPEN HEISST: nur ihre Aufgaben zeigen.
GEMESSEN STATT ANGENOMMEN: Auf dieser Seite gibt es KEIN
Suchfeld -- `#wer` in der Kopfleiste ist die Anzeige, wer
angemeldet ist. Deshalb filtert der Klick unmittelbar am Brett
und nicht ueber ein Feld, das es nicht gibt.
Und er wirkt auf die KARTEN, nicht auf die Datenliste: So
ueberlebt der Filter jedes Neuzeichnen nicht -- was richtig ist,
denn nach einer Aenderung will man wieder alles sehen. */
neu.querySelectorAll('.z-person').forEach((b) => {
b.addEventListener('click', () => {
const id = Number(b.dataset.person);
const anJetzt = b.dataset.gewaehlt !== 'ja';
neu.querySelectorAll('.z-person').forEach((x) => { delete x.dataset.gewaehlt; });
if (anJetzt) b.dataset.gewaehlt = 'ja';
personFilterSetzen(anJetzt ? id : null);
});
});
}
/** Zeigt nur die Karten, an denen diese Person beteiligt ist.
* `null` heisst: wieder alle. */
function personFilterSetzen(personId) {
const brett = $('brett');
if (!brett) return;
brett.querySelectorAll('.karte[data-id]').forEach((k) => {
if (personId === null) { k.hidden = false; return; }
/* ALLE NUMMERN DER KARTE, nicht nur die erste. Seit eine Karte
ein STUECK ARBEIT ist, stehen dort bis zu acht Aufgaben. Mit
nur der ersten verschwaende die Karte aus dem Filter, sobald
die gesuchte Person die zweite ist -- und das sieht aus wie
"diese Person hat nichts zu tun". */
const nummern = (k.dataset.ids || k.dataset.id || '')
.split(',').map(Number).filter(Number.isFinite);
const dabei = nummern.some((nr) => {
const a = alleAufgaben.find((x) => x.id === nr);
return !!a && (a.verantwortlich_id === personId
|| (a.zuteilung || []).some((z) => z.person_id === personId));
});
k.hidden = !dabei;
});
}
/* ===== AN MEHRERE VERTEILEN (21.09.2026, Abschnitt 3) ==============
DIE NAMEN WERDEN ABGELEITET, nicht zum zweiten Mal geholt: Sie
stehen schon in der Auswahl "Wer macht es?" darueber. Eine zweite
Liste waere die, die beim naechsten neuen Menschen veraltet.
`personenwahl` baut um das <select> herum eine eigene Bedienung;
die OPTIONEN bleiben dabei im Dokument stehen, und genau die
werden hier gelesen. */
function mehrereAufbauen() {
const kasten = $('feld-mehrere');
const auf = $('mehrere-auf');
const liste = $('mehrere-liste');
const leute = $('mehrere-leute');
const art = $('mehrere-art');
if (!kasten || !auf || !liste || !leute || !art) return;
/* Nur fuer die, die ueberhaupt verteilen duerfen -- sonst ist es
ein Angebot, das der Server ablehnt. */
if (!(ich?.darf_verteilen ?? LEITUNG.has(ich?.rolle))) return;
kasten.hidden = false;
auf.addEventListener('click', () => {
const zu = liste.hidden;
liste.hidden = !zu;
auf.setAttribute('aria-expanded', zu ? 'true' : 'false');
auf.textContent = zu ? 'Doch nur eine Person' : 'Noch jemanden dazunehmen';
if (zu) leuteFuellen();
else { leute.replaceChildren(); art.hidden = true; }
});
function leuteFuellen() {
const quelle = $('f-verant');
leute.replaceChildren();
if (!quelle) return;
const schon = String(quelle.value || '');
let zahl = 0;
for (const o of quelle.options) {
/* Der leere Eintrag ("noch niemand") ist keine Person, und wer
oben schon gewaehlt ist, steht nicht zweimal da. */
if (!o.value || o.value === schon) continue;
const id = 'mw-' + o.value;
const zeile = el('label', 'z-wahl__zeile');
const k = document.createElement('input');
k.type = 'checkbox'; k.value = o.value; k.id = id;
k.addEventListener('change', artZeigen);
zeile.append(k, el('span', null, o.textContent));
zeile.htmlFor = id;
leute.append(zeile);
zahl++;
}
if (!zahl) {
leute.append(el('p', 'feld-hinweis',
'Es gibt sonst niemanden, dem du sie geben könntest.'));
}
artZeigen();
}
/* DIE FRAGE "wie soll das laufen" ERSCHEINT ERST, WENN SIE SICH
STELLT -- also ab zwei Leuten. Vorher waere sie eine Entscheidung
ohne Gegenstand. */
function artZeigen() {
art.hidden = gewaehlteIds().length < 2;
}
}
/** Alle, die die Aufgabe bekommen sollen -- oben gewaehlt plus
* angehakt. Eine Quelle fuer das Absenden UND fuer die Anzeige. */
function gewaehlteIds() {
const aus = [];
const einzeln = $('f-verant')?.value;
if (einzeln) aus.push(Number(einzeln));
document.querySelectorAll('#mehrere-leute input[type=checkbox]:checked')
.forEach((k) => aus.push(Number(k.value)));
return [...new Set(aus.filter(Number.isInteger))];
}
async function laden() {
try {
const antwort = await hole('/workspace/api/aufgaben');
if (!antwort.ok) { melde('Aufgaben konnten nicht geladen werden.'); return; }
const daten = await antwort.json();
alleAufgaben = daten.aufgaben || [];
aufwandUebernehmen(daten);
kategorien = daten.kategorien || [];
kanaele = daten.kanaele || [];
/* `null` muss `null` BLEIBEN -- ein `|| []` daraus zu machen waere
der stille Fehler: Aus "gilt immer" wuerde "gilt nie", und das
Feld verschwaende fuer genau die, die es brauchen. */
kategorieFuer = daten.kategorie_fuer === undefined ? [] : daten.kategorie_fuer;
kategorienEinsetzen();
kategorieFeldZeigen('f');
kanaeleEinsetzen();
kanalFeldZeigen('f');
kanalFeldZeigen('b');
lageZeigen(alleAufgaben);
/* ERST DIE BEWERBUNGEN, DANN ZEICHNEN. Andersherum entstuende
das Brett ohne die Spalte und bekaeme sie erst beim naechsten
Mal -- ein Flackern, bei dem drei wartende Menschen eine
Sekunde lang nicht dastehen. */
await bewerbungenHolen();
neuZeichnen();
bandZeigen();
/* 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 || !darfAnlegen();
if (auf) $('f-titel').focus();
}
/* WER NICHT ANLEGEN DARF, SIEHT DEN KNOPF NICHT (22.09.2026).
------------------------------------------------------------------
Im Team Dogi kommen Aufgaben von der Leitung; ein Modi bekommt
sie. Der Server weist es ohnehin ab -- aber ein Knopf, der mit
403 antwortet, ist schlimmer als keiner: Die Meldung erscheint
ganz oben auf der Seite, und wer weiter unten steht, sieht nur,
dass nichts passiert.
`darf_aufgaben_anlegen` kommt vom Server. Ein Rollenname stuende
hier in einer Datei, die jeder herunterlaedt -- und waere am Tag
der naechsten Aenderung falsch. */
function darfAnlegen() {
/* WO ANGELEGT WIRD, ENTSCHEIDET DER SERVER (22.09.2026).
Filipe: „dieser button kann da jetzt doch endlich verschwinden,
auf dieser seite sollen ja keine aufgaben mehr verteilt werden."
Er hat dabei auf den Team-Dogi-Bildschirm gesehen. Auf der
Agenturadresse gibt es die Entwicklungsseite nicht -- dort waere
der Knopf ersatzlos weg, und vier Rollen koennten keine Aufgabe
mehr anlegen. Der Server sagt deshalb, WO der Ort ist; er leitet
es aus den Kacheln der Person ab.
`?? "aufgaben"` ist die vorsichtige Seite: Antwortet ein alter
Server ohne dieses Feld, bleibt der Knopf da. Ein Knopf zu viel
ist ein Schoenheitsfehler, ein fehlender eine Sackgasse. */
if ((ich?.aufgaben_anlegen_auf ?? 'aufgaben') !== 'aufgaben') return false;
return ich?.darf_aufgaben_anlegen !== false;
}
$('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,
/* DAUERHAFT (30.09.2026). Der Server nimmt es nur an, wenn die
Person verteilen darf -- steht das Feld gar nicht da, ist es
`false`, und das ist die richtige Vorgabe. */
dauerhaft: !!$('f-dauerhaft')?.checked,
};
/* DER SERVER SAGT ES (20.09.2026). Hier stand `LEITUNG.has(ich.rolle)`
-- eine zweite Fassung der Regel im Browser. Seit die rechte Hand
verteilen darf, war sie die falsche: Der Server erlaubt es, die
Seite zeigte die Felder nicht. `darf_verteilen` kommt aus
derselben Funktion wie die Schranke in der Route.
Die Ersatzantwort `LEITUNG.has(...)` bleibt fuer den Fall, dass
eine aeltere Fassung des Servers antwortet -- dann ist es wie
vorher, statt dass die Felder ganz verschwinden. */
if (ich.darf_verteilen ?? 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;
/* AN MEHRERE (21.09.2026). Wird nur mitgeschickt, wenn wirklich
jemand gewaehlt ist -- eine leere Liste hiesse "nimm allen
alles weg", und das waere beim Anlegen sicher nicht gemeint. */
const wer = gewaehlteIds();
if (wer.length) {
daten.zuteilung = wer;
daten.verteilart = wer.length < 2 ? 'einzeln'
: (document.querySelector('#mehrere-art input:checked')?.value || 'mehrere');
}
}
if (!$('feld-kategorie')?.hidden) daten.kategorie = $('f-kategorie').value || '';
if (!$('feld-kanal')?.hidden) daten.kanal = $('f-kanal').value || '';
knopfEl.disabled = true;
try {
const antwort = await hole('/workspace/api/aufgaben', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(daten),
});
if (!antwort.ok) {
const d = await antwort.json().catch(() => ({}));
$('neu-fehler').textContent = sagWas(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 || '';
/* DAUERHAFT (30.09.2026) -- fuellen und nur denen zeigen, die
verteilen duerfen. Der Server nimmt es ohnehin nur von ihnen
an; das hier ist die Hoeflichkeit davor. */
var bd = document.getElementById('b-dauerhaft');
var bdFeld = document.getElementById('b-feld-dauerhaft');
if (bd) bd.checked = !!a.dauerhaft;
if (bdFeld) bdFeld.hidden = !(ich.darf_verteilen ?? LEITUNG.has(ich.rolle));
$('b-fehler').textContent = '';
/* AUCH HIER DER SERVER (21.09.2026). Stand `LEITUNG.has(ich.rolle)`.
Fuer die rechte Hand blieben die Felder beim BEARBEITEN leer --
sie sah nicht einmal, wem die Aufgabe gerade gehoert. */
if (ich.darf_verteilen ?? LEITUNG.has(ich.rolle)) {
$('b-creator').value = a.creator_id ? String(a.creator_id) : '';
$('b-verant').value = a.verantwortlich_id ? String(a.verantwortlich_id) : '';
}
/* LOESCHEN HAENGT AN DER AUFGABE, NICHT AN DER PERSON (25.09.2026).
Hier stand der Knopf in derselben Bedingung wie die zwei Felder
darueber -- also an `darf_verteilen`. Das ist eine Auskunft
ueber die PERSON, und Loeschen ist eine Frage an die AUFGABE:
Die rechte Hand darf verteilen und sah den Knopf deshalb an
jeder Aufgabe, auch an fremden. Der Server hat dort mit 403
geantwortet -- ein Knopf, der eine Absage holt, ist schlimmer
als keiner.
`?? true` als Rueckfall: Kommt die Auskunft nicht mit (alte
Auslieferung im Zwischenspeicher), bleibt es beim alten
Verhalten, statt den Knopf ueberall zu verstecken. Die Schranke
steht ohnehin im Server. */
$('b-loeschen').hidden = !(a.darf_loeschen ?? true);
kategorieFeldZeigen('b');
if ($('b-kategorie')) $('b-kategorie').value = a.kategorie || '';
if ($('b-kanal')) $('b-kanal').value = a.kanal || '';
if ($('b-aufwand')) $('b-aufwand').value = a.aufwand || '';
window.anhaengeLaden?.(a.id);
dialog.showModal();
$('b-titel').focus();
}
$('b-abbrechen').addEventListener('click', () => dialog.close());
/* Der Abbruch-Dialog. Am submit des Formulars und nicht am Knopf:
So löst auch Enter im Textfeld aus, und Esc schließt von selbst
(beides bringt <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 = {
aufwand: $('b-aufwand') ? $('b-aufwand').value : undefined,
titel: $('b-titel').value,
beschreibung: $('b-text').value,
prioritaet: $('b-prio').value,
frist: $('b-frist').value,
dauerhaft: !!document.getElementById('b-dauerhaft')?.checked,
};
/* DIE GEFAEHRLICHSTE DER VIER STELLEN (berichtigt 21.09.2026).
Hier entscheidet sich, ob `verantwortlich_id` beim Speichern
MITGESCHICKT wird. Mit `LEITUNG.has(ich.rolle)` schickte die
rechte Hand es nicht mit -- sie haette also durch blosses
Bearbeiten einer Aufgabe deren Zuteilung stillschweigend
geloescht. Kein Fehler, keine Meldung; die Aufgabe gehoert
danach niemandem mehr, und niemand weiss, warum. */
if (ich.darf_verteilen ?? LEITUNG.has(ich.rolle)) {
daten.creator_id = $('b-creator').value || null;
daten.verantwortlich_id = $('b-verant').value || null;
daten.creator_extern = $('b-creator').dataset.extern || null;
daten.verantwortlich_extern = $('b-verant').dataset.extern || null;
}
if (!$('b-feld-kategorie')?.hidden) daten.kategorie = $('b-kategorie').value || '';
if (!$('b-feld-kanal')?.hidden) daten.kanal = $('b-kanal').value || '';
$('b-speichern').disabled = true;
try {
const a = await hole(`/workspace/api/aufgaben/${bearbeitet.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(daten),
});
if (!a.ok) {
$('b-fehler').textContent = sagWas((await a.json().catch(() => ({}))).fehler, 'Speichern ging nicht.');
return;
}
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
dialog.vergessen?.();
dialog.close();
await laden();
} catch { /* umgeleitet */ } finally { $('b-speichern').disabled = false; }
});
$('b-loeschen').addEventListener('click', async () => {
/* Loeschen ist hier fast nie das Richtige -- deshalb nennt der
Dialog den sanfteren Weg beim Namen, statt nur zu warnen. */
if (!await frageNach({
titel: `„${bearbeitet.titel}“ löschen?`,
was: 'Die Aufgabe wird samt Rückmeldungen und Verlauf entfernt.',
bleibt: 'Wenn sie nur nicht mehr stören soll: „Abbrechen“ legt sie '
+ 'beiseite, ohne etwas wegzuwerfen – dort ist sie später noch da.',
endgueltig: true, ja: 'Endgültig löschen',
})) return;
try {
const a = await hole(`/workspace/api/aufgaben/${bearbeitet.id}`, { method: 'DELETE' });
if (!a.ok) { $('b-fehler').textContent = 'Loeschen ging nicht.'; return; }
/* Gespeichert -- die Verwurfwache darf hier nicht fragen. */
dialog.vergessen?.();
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 {
/* NICHT MEHR STUMM (20.09.2026). Hier stand `catch { return; }`
-- und damit blieb die Seite bei jedem Netzaussetzer fuer
immer auf "wird geladen …" stehen. Gemessen auf 13 von 21
Seiten. `ladefehler()` sagt, was los ist, und bietet einen
Knopf an, der es noch einmal versucht. */
window.ladefehler?.();
return;
}
window.werZeigen?.(ich);
/* Jetzt erst steht fest, ob der Knopf dasteht -- `ich` kommt vom
Server, und vorher weiss diese Datei es nicht. */
$('neu-oeffnen').hidden = !darfAnlegen();
/* VON "EURE AUFGABEN" HERGEKOMMEN (22.09.2026).
----------------------------------------------------------------
Dort steht der Knopf "Aufgabe fuer <Name>". Er fuehrt hierher
und bringt zwei Dinge mit: dass das Formular offen sein soll und
fuer wen. Ohne das muesste man nach dem Klick noch zweimal
suchen -- und genau solche Zwischenschritte waren gemeint mit
"es darf keine unnoetigen schritte geben". */
try {
const frage = new URLSearchParams(location.search);
if (frage.get('neu') === '1' && darfAnlegen()) {
formularUmschalten(true);
const fuer = frage.get('fuer');
if (fuer) {
/* Die Auswahl steht erst, wenn die Leute geladen sind --
deshalb nachtraeglich und nur, wenn es die Person gibt. */
const setzen = () => {
const f = $('f-verant');
if (!f) return false;
if (![...f.options].some((o) => o.value === fuer)) return false;
f.value = fuer;
f.dispatchEvent(new Event('change'));
return true;
};
if (!setzen()) {
let versuche = 0;
const uhr = setInterval(() => {
if (setzen() || ++versuche > 40) clearInterval(uhr);
}, 150);
}
}
}
} catch (f) { /* unbrauchbare Adresse -> dann eben ohne */ }
/* EINE BEDINGUNG, NICHT ZWEI (berichtigt 21.09.2026).
Hier stand `LEITUNG.has(ich.rolle)` -- eine Menge aus
bereiche.js mit spicy, admin und manager. Beim ABSENDEN fragt
dieselbe Datei dagegen `ich.darf_verteilen`, und das sagt der
Server: Leitung ODER rechte Hand (seit 20.09.2026).
Folge: Die rechte Hand durfte Aufgaben verteilen und BEKAM DIE
FELDER NIE ZU SEHEN. Der Server haette ihre Zuteilung
angenommen; sie konnte sie nur nirgends eintragen. Gemessen am
21.09.: Feld vorhanden, `hidden` gesetzt, Auswahl leer.
Genau davor warnt die Zeile unten selbst ("eine aeltere Fassung
des Servers") -- der Rueckfall war als Notnagel gedacht und
stand hier als Hauptregel. Jetzt entscheidet an BEIDEN Stellen
dasselbe: was der Server sagt. */
if (ich.darf_verteilen ?? LEITUNG.has(ich.rolle)) {
$('feld-creator').hidden = false;
$('feld-verant').hidden = false;
$('b-feld-creator').hidden = false;
$('b-feld-verant').hidden = false;
try {
const { personen } = await (await hole('/workspace/api/personen')).json();
/* AUCH ALS DATEN BEHALTEN, nicht nur als <option>. Das
Vorlagenbrett braucht sie unten noch einmal -- und sie ein
zweites Mal zu holen waere ein zweiter Weg, auf dem eine
andere Liste herauskommen kann. */
personenListe = personen;
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'));
}
mehrereAufbauen();
for (const ziel of ['b-creator', 'b-verant']) {
$(ziel)?.addEventListener('change', () => kategorieFeldZeigen('b'));
}
}
await laden();
/* ERST DIE AUFGABEN, DANN DER KATALOG. Er markiert daran, was
schon uebernommen wurde -- in umgekehrter Reihenfolge waere die
Markierung beim ersten Zeichnen leer. */
/* NUR ZEICHNEN, WENN AUCH EINGERICHTET WURDE. `zeigen()` ohne
`einrichten()` liest aus einer Schnittstelle, die es nicht gibt
-- ein Fehler in der Konsole und ein halb gezeichneter Kasten. */
if (vorlagenbrettAnschliessen()) await window.Vorlagenbrett?.zeigen();
})();
/* =====================================================================
AUFGABEN AUS DER VORLAGE -- steht jetzt in einer eigenen Datei
=====================================================================
Der Katalog selbst liegt seit dem 23.09.2026 in
assets/js/vorlagenbrett.js, weil ihn ZWEI Seiten brauchen: hier
der Creator-Katalog (vier Bereiche, vier Stufen) und auf "Eure
Aufgaben" der Team-Katalog.
Filipe: "das soll auch bitte nicht in der kategorie aufgaben
sondern eure aufgaben sein bitte."
WAS HIER STEHEN BLEIBT, ist die Verbindung zu DIESER Seite: woher
die Aufgaben kommen, wer gerade gewaehlt ist, wie ein Fehler
gemeldet wird. Das ist an jeder Seite anders, und genau deshalb
nimmt die Katalogdatei es entgegen, statt es zu erraten.
ZWEIG 'creator': Der Team-Katalog wird hier NICHT gezeigt, auch
wenn der Server ihn mitschickt. Er hat seine Seite. */
function vorlagenbrettAnschliessen() {
if (!window.Vorlagenbrett) return false;
/* ==== OHNE CREATOR KEIN CREATOR-KATALOG (25.09.2026) ============
Filipe, zu einem Bildschirmfoto dieser Seite: „es gibt keine
creator auf dieser seite. die app ist nur fuer modis und
community also steht das falsch da."
Er hatte den Satz „Waehle oben einen Creator, dann lassen sich
diese Aufgaben uebernehmen" vor sich -- unter einem Katalog mit
vier Bereichen und vier Stufen, der fuer Creator gemacht ist.
Auf der Team-Adresse gibt es keine. Der Satz war also nicht nur
falsch, sondern eine Aufforderung zu etwas Unmoeglichem: Man
sucht oben nach einer Auswahl, die dort nie erscheinen wird.
GEFRAGT WIRD NACH DEN DATEN, NICHT NACH DER ADRESSE. „Gibt es
jemanden, dem ich das geben kann?" ist dieselbe Frage, egal auf
welcher Seite man steht -- und sie beantwortet sich aus der
Personenliste, die die Seite ohnehin schon geholt hat. Eine
Abfrage auf das Haus waere eine zweite Regel neben der
Personenliste, und die beiden koennten auseinanderlaufen.
Ein Creator selbst behaelt den Katalog immer: Er nimmt Aufgaben
fuer sich, dafuer braucht er niemanden zum Auswaehlen. */
const gibtCreator = ich?.rolle === 'creator'
|| (personenListe || []).some((p) => p.rolle === 'creator');
if (!gibtCreator) {
/* Der ganze Kasten geht weg, nicht nur der Satz. Eine
Ueberschrift ueber einem Katalog, den niemand uebernehmen
kann, ist dieselbe Sackgasse in leiser. */
const kasten = document.getElementById('vorlagenbrett');
if (kasten) kasten.hidden = true;
return false;
}
window.Vorlagenbrett.einrichten({
zweig: 'creator',
hole,
/* Zwei Angaben, weil der Katalog auch Gutes meldet (siehe
entwicklung.js). Hier nimmt "melde" nur den Text -- der
zweite Wert faellt weg, ohne zu stoeren. */
melde: (text) => melde(text),
statusName: STATUS_NAME,
ich: () => ich,
aufgaben: () => alleAufgaben,
/* FUER WEN wird uebernommen? Ein Creator meint sich selbst; ein
Betreuer den, den er oben in der Reihe gewaehlt hat. Diese
Entscheidung steht hier und nicht in der Katalogdatei: Sie
haengt an "nurCreator", und das lebt auf dieser Seite. */
ziel: () => (ich?.rolle === 'creator' ? ich.id : nurCreator) || null,
zielName: () => {
const id = ich?.rolle === 'creator' ? ich.id : nurCreator;
if (!id) return null;
if (ich?.rolle === 'creator') return null;
return personenListe.find((p) => String(p.id) === String(id))?.name || null;
},
/* DER CREATOR-ZWEIG HAT KEINE PERSONENREIHE -- dort waehlt man
oben einen Creator, und der Katalog folgt ihm. Eine leere
Liste heisst: Diese Reihe wird hier nicht gezeichnet. Die
Angaben stehen trotzdem da, weil die Katalogdatei sie
verlangt: Ein fehlendes Feld soll auffallen, nicht stumm als
"will ich nicht" durchgehen. */
zielbare: () => [],
zielWaehlen: () => {},
nachAenderung: () => laden(),
});
return true;
}
})();
/* =====================================================================
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;
/* MIT FORTSCHRITT UND ABBRUCH (19.09.2026). `sagen('Wird
hochgeladen …')` stand hier von der ersten bis zur letzten
Sekunde unveraendert. */
let lauf = null, anzeige = null;
try {
anzeige = hochladeAnzeige(stand, f.name, () => lauf && lauf.abbrechen());
lauf = hochladen('/workspace/api/dateien', f, {
kopf: {
'Content-Type': f.type || 'application/octet-stream',
'x-dateiname': encodeURIComponent(f.name),
'x-aufgabe': String(fuerAufgabe),
},
beiFortschritt: anzeige.fortschritt,
beiVerarbeitung: anzeige.verarbeiten,
});
const ergebnis = await lauf.fertig;
if (ergebnis.abgebrochen) {
anzeige.abschluss('Abgebrochen – nichts wurde angehängt.');
anzeige = null;
return;
}
if (!ergebnis.ok) {
anzeige.weg(); anzeige = null;
sagen(ergebnis.netz ? 'Keine Verbindung.'
: (ergebnis.daten?.fehler === 'zu_gross' ? 'Die Datei ist zu groß.'
: sagWas(ergebnis.daten?.fehler, 'Das hat nicht geklappt.')));
return;
}
anzeige.weg(); anzeige = null;
sagen('Angehängt.');
await window.anhaengeLaden(fuerAufgabe);
} catch {
sagen('Keine Verbindung.');
} finally {
if (anzeige) anzeige.weg();
/* Zuruecksetzen, sonst loest dieselbe Datei kein zweites Mal aus. */
feld.value = '';
}
});
})();
/* =====================================================================
DIE AUFWANDSSTUFEN KOMMEN VOM SERVER (15.09.2026)
Die Auswahl wird aus der Liste gebaut, die mit den Aufgaben kommt --
nicht aus drei <option>-Zeilen im HTML. Eine zweite Liste im Browser
waere die, die beim naechsten Wert veraltet; genau dieser Fehler ist
im Projekt schon bei den Rollen und bei den Brettern passiert.
===================================================================== */
(() => {
const feld = document.getElementById('b-aufwand');
if (!feld) return;
window.aufwandStufenSetzen = function (stufen) {
if (!Array.isArray(stufen) || !stufen.length) return;
/* Der gewaehlte Wert muss den Neuaufbau ueberleben -- sonst steht
im Dialog etwas anderes als in der Aufgabe. */
const vorher = feld.value;
feld.textContent = '';
const leer = document.createElement('option');
leer.value = '';
leer.textContent = 'noch nicht eingeschätzt';
feld.append(leer);
for (const s of stufen) {
const o = document.createElement('option');
o.value = s.schluessel;
o.textContent = s.etwa ? s.name + ' · ' + s.etwa : s.name;
feld.append(o);
}
feld.value = vorher;
};
})();
/* =====================================================================
DIE LEISTE FUER DIE TEXTGESTALTUNG (21.09.2026)
=====================================================================
EIGENER BLOCK, mit Absicht. Erst stand das am Ende des Blocks
darueber -- und der beginnt mit `if (!feld) return;` auf ein Feld,
das mit der Textgestaltung nichts zu tun hat. Damit haette ein
entfernter Aufwand-Waehler die Gestaltung auf der ganzen Seite
mitgenommen, ohne dass irgendwo etwas kaputt aussieht.
Und ohne `$`: Diese Datei hat drei getrennte Bloecke, und der
Helfer ist nur in zweien davon erklaert.
Beide Felder -- das beim Anlegen und das beim Bearbeiten. Sie stehen
im HTML von Anfang an da, auch solange das Formular versteckt ist;
deshalb reicht ein Aufruf beim Laden. `leiste` merkt sich am Feld
selbst, dass sie schon gebaut wurde, ein zweiter Aufruf schadet also
nicht.
===================================================================== */
(() => {
if (!window.Textform) return;
for (const id of ['f-text', 'b-text']) {
const feld = document.getElementById(id);
if (feld) window.Textform.leiste(feld);
}
})();