Filipe: "Es darf vor allem keine Stellen geben, an denen ein Benutzer
etwas falsch machen kann, nur weil die Seite es nicht verstaendlich
genug erklaert."
Gemessen: 30 Stellen in 16 Dateien benutzten confirm() oder prompt().
Das Haus hatte die richtige Bauweise laengst -- einen <dialog>, in
aufgaben.html sogar ausfuehrlich begruendet -- aber sie stand IN EINER
SEITE. Wer anderswo etwas loeschen liess, hatte sie nicht.
confirm('Wirklich loeschen?') stellt die falsche Frage: Es fragt, ob
man sicher ist, und nennt nicht, WAS passiert, was BLEIBT und ob es
ZURUECK geht. Jetzt beantwortet jeder der 41 Dialoge alle drei.
Neu: workspace/assets/js/nachfrage.js -- window.frageNach() mit
Pflichtgrund, Zahlenfeld, einzeiliger Eingabe und Abtippsicherung.
Drei Ausgaenge: <dialog> / confirm()-Notnagel fuer Safari vor 15.4 /
Abbruch (Esc, Klick daneben, "Doch nicht" -- immer false).
DREIMAL DERSELBE FALLSTRICK, dreimal nachgemessen statt vermutet:
.dialog stand in aufgaben.css und leistung.css -> auf dateien.html
waere der Dialog ein weisser Systemkasten gewesen. 14 Regeln
klammergenau nach module.css verschoben (Klammern gezaehlt, nicht
per Muster geschnitten -- heute frueh hat ein nicht-gieriges
Muster schon einmal CSS zerrissen).
Das Formular trug .neu neu--blank -- und .neu gibt seine Abstaende
nur in aufgaben.css. Gemessen: padding 0px, und die Felder
verloren ihre height:44px. Jetzt steht alles unter
.nachfrage__form in module.css; der Dialog borgt nichts mehr.
Die erste Fassung der Pruefung zaehlte nachfrage.js SELBST als
Nutzer -- damit war jede Seite trivialerweise "Nutzer" und die
Pruefung gruen ohne Inhalt. Jetzt ausdruecklich ausgenommen.
ZWEI FUNDE NEBENBEI:
hilfeAufraeumen() wird im Betrieb NIE aufgerufen. Der Kommentar
behauptete "wird beim Start aufgerufen (siehe index.js)" -- das
war nie wahr; einziger Aufrufer ist die eigene Pruefung. Folge:
geschlossene vertrauliche Faelle bleiben unbegrenzt stehen. NICHT
eingeschaltet (das loescht echte Daten und ist Filipes
Entscheidung), sondern der Kommentar richtiggestellt.
Einen Hilfe-Fall zu schliessen ist endgueltig -- es gibt keine
Route, die ihn wieder oeffnet. Vorher stand darueber nur die
Frage nach einem Schlusswort. Jetzt sagt der Dialog es.
pruef-struktur hat meine eigene Pruefung von heute Nachmittag
erwischt: Sie bildete ihr Datum aus UTC. Beim Beheben erst
heuteLokal(datum) genommen -- die Funktion nimmt gar kein Argument
und haette still "heute" statt "+3 Tage" geliefert. Jetzt tagLokal(3),
nachgerechnet: Abstand 3 Tage.
Am Bildschirm angesehen (Rechner 1280, Handy 390): passt rein, Esc
ergibt false, Fokus liegt auf dem harmlosen Knopf, Knoepfe 44px auf
Touch. Der Platzhalter im Abtippfeld zeigte den erwarteten Namen --
das sah aus wie ein schon ausgefuelltes Feld, entfernt.
Neu: server/pruef-nachfrage.mjs -- 17/0, mit sechs Gegenproben und
beiden Richtungen (wer fragt, laedt die Datei; wer nie fragt, laedt
sie nicht -- sonst truege die Anmeldewand 4,8 KB fuer nichts).
pruef-meldungen 8/0, pruef-css-klassen gruen, pruef-struktur gruen,
pruef-leistung gruen.
1786 lines
76 KiB
JavaScript
1786 lines
76 KiB
JavaScript
/* ===================================================================
|
||
Aufgabenbrett.
|
||
|
||
Der Server entscheidet, wer was sehen und ändern darf -- diese Datei
|
||
zeigt nur an. Was hier ausgeblendet wird, ist Bequemlichkeit, kein
|
||
Schutz.
|
||
=================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
/* DogFather und Manager haben dieselben Rechte -- die Unterscheidung
|
||
macht nur die Personenverwaltung, und die prueft der Server. */
|
||
/* Die Leitungsliste steht in bereiche.js -- eine Antwort statt neun.
|
||
Sie stand hier frueher selbst, ohne 'spicy', und genau das war der
|
||
Fehler, den Filipe am 07.09.2026 gefunden hat. */
|
||
const LEITUNG = window.Bereiche.LEITUNG;
|
||
|
||
/* `leer` ist der Satz, der in einer leeren Spalte steht. Vorher stand
|
||
dort in allen vier Spalten ein Gedankenstrich -- vier graue Striche
|
||
nebeneinander sehen aus, als sei die Seite kaputt, obwohl gar nichts
|
||
kaputt ist. Jeder Satz sagt jetzt, was das Leersein BEDEUTET: eine
|
||
leere Review-Spalte ist etwas anderes als eine leere Erledigt-Spalte.
|
||
`unter` erklärt die Spalte auch dann, wenn sie voll ist. */
|
||
const SPALTEN = [
|
||
{ status: 'offen', name: 'Offen', unter: 'Angenommen, noch nicht angefangen',
|
||
leer: 'Nichts liegt herum.' },
|
||
{ status: 'arbeit', name: 'In Arbeit', unter: 'Läuft gerade',
|
||
leer: 'Gerade läuft nichts.' },
|
||
{ status: 'review', name: 'Review', unter: 'Wartet auf Freigabe',
|
||
leer: 'Nichts wartet auf Freigabe.' },
|
||
{ status: 'erledigt', name: 'Erledigt', unter: 'Fertig und abgehakt',
|
||
leer: 'Hier landet, was fertig ist.' },
|
||
];
|
||
const WEITER = { offen: 'arbeit', arbeit: 'review', review: 'erledigt' };
|
||
const ZURUECK = { arbeit: 'offen', review: 'arbeit', erledigt: 'review' };
|
||
|
||
/* Wer darf abbrechen (05.09.2026): *"nur ich die manager und scouts
|
||
sollen auch die aufgaben abbrechen können."* Ein Creator nicht.
|
||
|
||
Dieselbe Menge steht im Server (DARF_ABBRECHEN in
|
||
workspace-aufgaben.js) und DORT sitzt die Sperre. Hier geht es nur
|
||
darum, niemandem einen Knopf hinzustellen, der dann 403 sagt. */
|
||
/* Leitung plus Scouts. Aus der einen Rollenquelle abgeleitet statt
|
||
abgeschrieben -- die abgeschriebene Fassung hatte 'spicy'
|
||
vergessen, und ein fehlender Knopf faellt niemandem auf. */
|
||
const DARF_ABBRECHEN = new Set([...window.Bereiche.LEITUNG, 'scout']);
|
||
const STATUS_NAME = {
|
||
offen: 'Offen', arbeit: 'In Arbeit', review: 'Review', erledigt: 'Erledigt',
|
||
};
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
/* ORTSZEIT, nicht UTC -- sonst gilt nachts eine heute faellige Aufgabe
|
||
noch nicht als faellig, und "Heute faellig" zeigt den Vortag. */
|
||
const heute = window.heuteLokal();
|
||
let ich = null;
|
||
/* Nur beim ERSTEN Laden springen. Sonst reisst es die Seite nach
|
||
jeder Statusaenderung erneut an dieselbe Stelle. */
|
||
let gesprungen = false;
|
||
|
||
/* Texte werden ausschließlich über textContent gesetzt, nie über
|
||
innerHTML -- ein Aufgabentitel darf keine Auszeichnung einschleusen. */
|
||
const el = (tag, klasse, text) => {
|
||
const k = document.createElement(tag);
|
||
if (klasse) k.className = klasse;
|
||
if (text !== undefined) k.textContent = text;
|
||
return k;
|
||
};
|
||
|
||
const melde = (text) => { $('fehler').textContent = text || ''; };
|
||
|
||
async function hole(pfad, optionen = {}) {
|
||
const antwort = await fetch(pfad, { credentials: 'same-origin', ...optionen });
|
||
if (antwort.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
|
||
return antwort;
|
||
}
|
||
|
||
/* ---------- Brett zeichnen ------------------------------------------ */
|
||
|
||
function datumHuebsch(iso) {
|
||
const [j, m, t] = iso.split('-');
|
||
return `${t}.${m}.${j.slice(2)}`;
|
||
}
|
||
|
||
/* ---------- Rückmeldungen ------------------------------------------------
|
||
Direkt an der Karte, nicht in einem Fenster: Man liest sie im
|
||
Zusammenhang mit der Aufgabe, sonst fehlt beim Antworten die Hälfte. */
|
||
|
||
const zeitpunkt = (iso) => {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
const p = (n) => String(n).padStart(2, '0');
|
||
return `${p(d.getDate())}.${p(d.getMonth() + 1)}. · ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||
};
|
||
|
||
async function rueckmeldungen(karteEl, a, knopfEl) {
|
||
const vorhanden = karteEl.querySelector('.rueck');
|
||
if (vorhanden) { vorhanden.remove(); delete knopfEl.dataset.offen; return; }
|
||
knopfEl.dataset.offen = 'ja';
|
||
|
||
const box = el('div', 'rueck');
|
||
box.append(el('p', 'leise', 'Wird geladen …'));
|
||
karteEl.append(box);
|
||
|
||
let daten;
|
||
try {
|
||
const antwort = await hole(`/workspace/api/aufgaben/${a.id}/notizen`);
|
||
if (!antwort.ok) {
|
||
box.textContent = '';
|
||
box.append(el('p', 'fehler', 'Konnte nicht geladen werden.'));
|
||
return;
|
||
}
|
||
daten = await antwort.json();
|
||
} catch { return; }
|
||
|
||
box.textContent = '';
|
||
const liste = el('div', 'rueck__liste');
|
||
if (!daten.notizen.length) liste.append(el('p', 'leise', 'Noch nichts besprochen.'));
|
||
for (const n of daten.notizen) {
|
||
const z = el('div', 'rueck__eintrag');
|
||
const kopf = el('p', 'rueck__kopf');
|
||
kopf.append(el('span', 'rueck__wer', n.von || 'Unbekannt'));
|
||
kopf.append(el('span', 'rueck__wann', zeitpunkt(n.erstellt)));
|
||
/* Die eigene Rückmeldung darf jeder zurücknehmen, fremde nur die
|
||
Leitung -- wer sich vertippt hat, soll das nicht beantragen
|
||
müssen. */
|
||
if (n.person_id === daten.ich || daten.darf_alles_loeschen) {
|
||
const weg = el('button', 'rueck__weg', '×');
|
||
weg.type = 'button';
|
||
weg.title = 'Rückmeldung entfernen';
|
||
weg.setAttribute('aria-label', 'Rückmeldung entfernen');
|
||
weg.addEventListener('click', async () => {
|
||
if (!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();
|
||
}
|
||
|
||
function karte(a) {
|
||
const k = el('article', 'karte');
|
||
k.dataset.prio = a.prioritaet;
|
||
/* DIE NUMMER AN DER KARTE. Ohne sie kann kein Verweis auf eine
|
||
EINZELNE Aufgabe zeigen -- und drei davon gibt es im Haus. */
|
||
k.dataset.id = String(a.id);
|
||
|
||
const kopf = el('div', 'karte__kopf');
|
||
kopf.append(el('h3', 'karte__titel', a.titel));
|
||
const stift = el('button', 'karte__stift', 'bearbeiten');
|
||
stift.type = 'button';
|
||
stift.setAttribute('aria-label', `${a.titel} bearbeiten`);
|
||
stift.addEventListener('click', () => dialogOeffnen(a));
|
||
kopf.append(stift);
|
||
k.append(kopf);
|
||
if (a.beschreibung) k.append(el('p', 'karte__text', a.beschreibung));
|
||
|
||
const zeile = el('div', 'karte__zeile');
|
||
/* WICHTIG STEHT DA, statt nur eine Kante einzufaerben.
|
||
|
||
Die Prioritaet gab es als 3 px breiten Rand links -- direkt neben
|
||
der farbigen Kante der Spalte, und damit praktisch unsichtbar.
|
||
Als Wort steht sie da, wo man sie liest.
|
||
|
||
NUR bei "hoch". Eine Marke an jeder Karte waere keine Marke mehr:
|
||
Was immer da steht, sieht man nach zwei Tagen nicht mehr. */
|
||
if (a.prioritaet === 'hoch' && a.status !== 'erledigt') {
|
||
zeile.append(el('span', 'karte__wichtig', 'wichtig'));
|
||
}
|
||
/* ---- WIE LANGE GEHT DAS SCHON SO? (15.09.2026, Stufe 9) --------
|
||
|
||
"Ueberfaellig" war ein Schalter: an oder aus. Eine Karte, deren
|
||
Frist gestern lief, sah aus wie eine, die seit drei Wochen liegt.
|
||
Stehen auf einem Brett zwanzig Karten "ueberfaellig", sagt keine
|
||
davon mehr etwas -- man ueberliest sie alle, auch die, die seit
|
||
einem Monat blockiert.
|
||
|
||
DAS WORT KOMMT VOM SERVER. Eine zweite Rechnung hier waere die,
|
||
die irgendwann anders antwortet als die Startseite. */
|
||
if (a.eskalation) {
|
||
const e = el('span', 'karte__eskal', a.eskalation.wort);
|
||
e.dataset.stufe = a.eskalation.stufe;
|
||
e.title = a.eskalation.grund === 'frist'
|
||
? 'Die Frist ist vorbei. Die Zahl sagt, seit wann.'
|
||
: 'Ohne Frist – seit so vielen Tagen hat niemand die Karte angefasst.';
|
||
zeile.append(e);
|
||
}
|
||
|
||
if (a.frist) {
|
||
/* Das Wort "ueberfaellig" steht jetzt oben in der Stufe. Hier
|
||
bleibt nur noch das Datum -- zweimal dasselbe nebeneinander
|
||
waere Laerm. */
|
||
const faellig = !!a.eskalation && a.eskalation.grund === 'frist';
|
||
const f = el('span', 'karte__frist' + (faellig ? ' karte__frist--faellig' : ''),
|
||
'bis ' + datumHuebsch(a.frist));
|
||
zeile.append(f);
|
||
}
|
||
/* Die Kategorie als Wort in derselben Zeile wie Frist und Person.
|
||
Kein eigener Streifen und keine eigene Farbe: Sie ist eine
|
||
Einordnung, keine Warnung -- und eine Karte, auf der alles
|
||
hervorsticht, hebt nichts hervor. Der Name kommt aus der Liste
|
||
des Servers; steht die Aufgabe in einer Kategorie, die es nicht
|
||
mehr gibt, wird nichts angezeigt statt eines Schluessels.
|
||
|
||
KEINE EIGENE CSS-KLASSE. Sie muesste in sieben gleichlautenden
|
||
Kopien der Modulliste gepflegt werden (pruef-css-klassen wacht
|
||
darueber, dass die sieben Zeichen fuer Zeichen gleich sind) --
|
||
viel Aufwand und sieben Gelegenheiten fuer einen Unterschied,
|
||
fuer eine Zeile Text. Dieselbe schlichte Form wie "· Bereich X"
|
||
daneben tut es. */
|
||
/* DER KANAL STEHT VOR DER KATEGORIE. Er beantwortet die groebere
|
||
Frage -- WO ueberhaupt -- und die muss man zuerst wissen, um die
|
||
feinere zu verstehen. */
|
||
if (a.kanal) {
|
||
const kName = kanaele.find((k) => k.wert === a.kanal)?.name;
|
||
if (kName) {
|
||
const z = el('span', 'aufgabe__kanal', kName);
|
||
z.dataset.kanal = a.kanal;
|
||
zeile.append(z);
|
||
}
|
||
}
|
||
if (a.kategorie) {
|
||
const name = kategorien.find((k) => k.wert === a.kategorie)?.name;
|
||
if (name) zeile.append(el('span', null, '· ' + name));
|
||
}
|
||
if (a.verantwortlich_name) zeile.append(el('span', null, '· ' + a.verantwortlich_name));
|
||
if (LEITUNG.has(ich.rolle) && a.creator_name && a.creator_name !== a.verantwortlich_name) {
|
||
zeile.append(el('span', null, '· Bereich ' + a.creator_name));
|
||
}
|
||
if (zeile.childElementCount) k.append(zeile);
|
||
|
||
const schritte = el('div', 'karte__schritte');
|
||
if (ZURUECK[a.status]) schritte.append(knopf('◀ zurück', a, ZURUECK[a.status]));
|
||
if (WEITER[a.status]) schritte.append(knopf(beschriftung(a.status), a, WEITER[a.status]));
|
||
/* ABBRECHEN — in JEDEM Status (05.09.2026), auch im erledigten:
|
||
Auch etwas Abgehaktes kann sich als hinfällig herausstellen.
|
||
|
||
Nur DogFather, Manager und Scouts. Ein Creator sieht den Knopf
|
||
gar nicht erst -- die eigentliche Sperre sitzt aber im Server,
|
||
hier geht es nur darum, niemandem etwas anzubieten, das dann
|
||
abgelehnt wird. */
|
||
if (DARF_ABBRECHEN.has(ich.rolle)) {
|
||
const ab = el('button', 'schritt schritt--ab', 'abbrechen');
|
||
ab.type = 'button';
|
||
ab.setAttribute('aria-label', `„${a.titel}" abbrechen`);
|
||
ab.addEventListener('click', () => abbruchOeffnen(a));
|
||
schritte.append(ab);
|
||
}
|
||
if (schritte.childElementCount) k.append(schritte);
|
||
|
||
/* Rückmeldungen ("Aufgaben & Feedback" aus dem Konzept). Der Knopf
|
||
zeigt gleich die Anzahl -- so sieht man ohne Aufklappen, wo etwas
|
||
besprochen wurde. */
|
||
const rueck = el('button', 'karte__rueck');
|
||
rueck.type = 'button';
|
||
rueck.dataset.hat = a.notizen ? 'ja' : 'nein';
|
||
const blase = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
blase.setAttribute('viewBox', '0 0 24 24');
|
||
blase.setAttribute('class', 'karte__blase');
|
||
blase.setAttribute('aria-hidden', 'true');
|
||
blase.innerHTML = '<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 = sagWas(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(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: 'zum Review ▶', review: 'erledigt ▶',
|
||
})[status];
|
||
|
||
function knopf(text, aufgabe, neuerStatus) {
|
||
const b = el('button', 'schritt', text);
|
||
b.type = 'button';
|
||
b.addEventListener('click', async () => {
|
||
b.disabled = true;
|
||
melde('');
|
||
try {
|
||
const antwort = await hole(`/workspace/api/aufgaben/${aufgabe.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status: neuerStatus }),
|
||
});
|
||
if (!antwort.ok) {
|
||
const d = await antwort.json().catch(() => ({}));
|
||
melde(d.fehler === 'nicht_erlaubt'
|
||
? 'Diese Aufgabe darfst du nicht ändern.'
|
||
: 'Ändern hat nicht geklappt.');
|
||
b.disabled = false;
|
||
return;
|
||
}
|
||
await laden();
|
||
} catch { b.disabled = false; }
|
||
});
|
||
return b;
|
||
}
|
||
|
||
/* ---------- Die Leiste ueber dem Brett -----------------------------------
|
||
|
||
Wunsch vom 01.09.2026: "verbessere die Aufgabenseite auch bei
|
||
Manager, Scout und Creator. Die Scouts und Manager sollen vielleicht
|
||
ein bisschen mehr Optionen haben, wie ihre zugeteilten Creator.
|
||
Aber auch die Creator sollen es besser und detaillierter sehen."
|
||
|
||
Drei Dinge, und die Reihenfolge ist die Aussage:
|
||
|
||
1. WAS BRENNT. Ein Satz beim Reinkommen, ob etwas ueberfaellig ist.
|
||
Ein Brett mit vier Spalten beantwortet das nicht -- man muss vier
|
||
Spalten durchsehen, um zu wissen, ob man ruhig anfangen kann.
|
||
|
||
2. AUSSCHNITTE. "Nur meine", "Heute faellig", "Ueberfaellig" -- die
|
||
Fragen, die man wirklich hat. Sie filtern das Brett, statt eine
|
||
zweite Liste aufzumachen: Vier Spalten nebeneinander sind der
|
||
Wert dieser Seite, den nimmt man nicht weg.
|
||
|
||
3. DIE CREATOR, nur fuer Betreuer. Wer drei betreut, will sehen,
|
||
was bei einem davon liegt -- ohne die anderen zwei dazwischen.
|
||
Ein Creator bekommt diese Reihe nicht: Er hat nur sich, und ein
|
||
Filter mit einem einzigen Eintrag ist ein Knopf, der nichts tut.
|
||
|
||
Alles hier ist ANSICHT. Wer was sehen darf, hat der Server
|
||
entschieden, bevor eine einzige Aufgabe hier ankommt. */
|
||
|
||
let ausschnitt = ''; /* '', 'meine', 'faellig', 'heute' */
|
||
let nurCreator = 0; /* 0 = alle */
|
||
let alleAufgaben = [];
|
||
|
||
/* Abgebrochenes zählt NICHT als offen (05.09.2026). Ohne diese
|
||
Ergänzung stünde eine abgebrochene Aufgabe weiter in "3 Aufgaben
|
||
stehen an" und in "Überfällig" -- und würde als etwas gemahnt, das
|
||
ausdrücklich niemand mehr tun soll. */
|
||
const istOffen = (a) => a.status !== 'erledigt' && a.status !== 'abgebrochen';
|
||
const istFaellig = (a) => istOffen(a) && a.frist && a.frist < heute;
|
||
const istHeute = (a) => istOffen(a) && a.frist === heute;
|
||
const istMeine = (a) => a.verantwortlich_id === ich.id;
|
||
|
||
function passt(a) {
|
||
if (nurCreator && a.creator_id !== nurCreator) return false;
|
||
if (ausschnitt === 'meine') return istMeine(a);
|
||
if (ausschnitt === 'faellig') return istFaellig(a);
|
||
if (ausschnitt === 'heute') return istHeute(a);
|
||
return true;
|
||
}
|
||
|
||
function lageZeigen(alle) {
|
||
const ziel = $('lage');
|
||
if (!ziel) return;
|
||
const faellig = alle.filter(istFaellig).length;
|
||
const heuteN = alle.filter(istHeute).length;
|
||
const offen = alle.filter(istOffen).length;
|
||
const meine = alle.filter((a) => istOffen(a) && istMeine(a)).length;
|
||
|
||
ziel.textContent = '';
|
||
let satz, stufe;
|
||
if (faellig) {
|
||
satz = `${faellig} ${faellig === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} überfällig – das zuerst.`;
|
||
stufe = 'warnung';
|
||
} else if (heuteN) {
|
||
satz = `${heuteN} ${heuteN === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} heute fällig.`;
|
||
stufe = 'offen';
|
||
} else if (offen) {
|
||
satz = `${offen} ${offen === 1 ? 'Aufgabe steht' : 'Aufgaben stehen'} an, nichts davon brennt.`;
|
||
stufe = 'offen';
|
||
} else {
|
||
satz = 'Nichts offen – alles abgearbeitet.';
|
||
stufe = 'ruhig';
|
||
}
|
||
ziel.dataset.stufe = stufe;
|
||
ziel.append(el('span', 'lage__satz', satz));
|
||
/* Die eigene Zahl steht daneben, nicht im Satz: Auf einem Brett, das
|
||
auch fremde Aufgaben zeigt, ist "wie viel davon ist meins" die
|
||
zweite Frage -- aber eben die zweite. */
|
||
if (ich.rolle !== 'creator' && meine) {
|
||
ziel.append(el('span', 'lage__meins', `${meine} davon bei dir`));
|
||
}
|
||
ziel.hidden = false;
|
||
}
|
||
|
||
function ausschnitteBauen(alle) {
|
||
const ziel = $('ausschnitte');
|
||
if (!ziel) return;
|
||
ziel.textContent = '';
|
||
|
||
const knopf = (wert, name, anzahl) => {
|
||
const b = el('button', 'schnitt');
|
||
b.type = 'button';
|
||
b.dataset.wert = wert;
|
||
b.setAttribute('aria-pressed', String(ausschnitt === wert));
|
||
b.append(el('span', null, name));
|
||
/* Die Zahl steht am Knopf. Ein Filter, der sich erst nach dem
|
||
Klick als leer herausstellt, kostet zweimal Aufmerksamkeit. */
|
||
b.append(el('span', 'schnitt__zahl', String(anzahl)));
|
||
if (!anzahl && wert) b.disabled = true;
|
||
b.addEventListener('click', () => {
|
||
ausschnitt = ausschnitt === wert ? '' : wert;
|
||
neuZeichnen();
|
||
});
|
||
return b;
|
||
};
|
||
|
||
ziel.append(knopf('', 'Alle', alle.length));
|
||
if (ich.rolle !== 'creator') {
|
||
ziel.append(knopf('meine', 'Nur meine',
|
||
alle.filter((a) => istOffen(a) && istMeine(a)).length));
|
||
}
|
||
ziel.append(knopf('heute', 'Heute fällig', alle.filter(istHeute).length));
|
||
const f = knopf('faellig', 'Überfällig', alle.filter(istFaellig).length);
|
||
f.dataset.warn = 'ja';
|
||
ziel.append(f);
|
||
ziel.hidden = false;
|
||
}
|
||
|
||
function creatorReiheBauen(alle) {
|
||
const ziel = $('creator-reihe');
|
||
if (!ziel) return;
|
||
ziel.textContent = '';
|
||
if (ich.rolle === 'creator') { ziel.hidden = true; return; }
|
||
|
||
/* Die Liste kommt aus den Aufgaben selbst, nicht aus einer
|
||
Personenabfrage: Dann stehen dort genau die Creator, die man
|
||
ohnehin sehen darf -- und niemals einer mehr. */
|
||
const nach = new Map();
|
||
for (const a of alle) {
|
||
if (!a.creator_id || !a.creator_name) continue;
|
||
const w = nach.get(a.creator_id) || { name: a.creator_name, offen: 0, faellig: 0 };
|
||
if (istOffen(a)) w.offen++;
|
||
if (istFaellig(a)) w.faellig++;
|
||
nach.set(a.creator_id, w);
|
||
}
|
||
/* Unter zwei Creatorn ist die Reihe eine Zeile ohne Auswahl. */
|
||
if (nach.size < 2) { ziel.hidden = true; return; }
|
||
|
||
/* "Deine Creator" stimmt fuer Scout und Manager -- bei DogFather
|
||
waeren es ALLE, und "deine" waere dort eine falsche Auskunft. */
|
||
ziel.append(el('span', 'creator-reihe__schild',
|
||
ich.rolle === 'admin' ? 'Alle Creator' : 'Deine Creator'));
|
||
const knopf = (id, name, offen, faellig) => {
|
||
const b = el('button', 'creator-knopf');
|
||
b.type = 'button';
|
||
b.setAttribute('aria-pressed', String(nurCreator === id));
|
||
b.append(el('span', 'creator-knopf__name', name));
|
||
b.append(el('span', 'creator-knopf__zahl', String(offen)));
|
||
if (faellig) {
|
||
const m = el('span', 'creator-knopf__warn', String(faellig));
|
||
m.title = `${faellig} überfällig`;
|
||
b.append(m);
|
||
}
|
||
b.addEventListener('click', () => {
|
||
nurCreator = nurCreator === id ? 0 : id;
|
||
neuZeichnen();
|
||
});
|
||
return b;
|
||
};
|
||
ziel.append(knopf(0, 'Alle', alle.filter(istOffen).length, alle.filter(istFaellig).length));
|
||
for (const [id, w] of [...nach.entries()].sort((a, b) => b[1].offen - a[1].offen)) {
|
||
ziel.append(knopf(id, w.name, w.offen, w.faellig));
|
||
}
|
||
ziel.hidden = false;
|
||
}
|
||
|
||
function neuZeichnen() {
|
||
ausschnitteBauen(alleAufgaben);
|
||
creatorReiheBauen(alleAufgaben);
|
||
zeichne(alleAufgaben.filter(passt));
|
||
/* Der Bereich folgt dem Creator-Filter mit -- wer sich eine Person
|
||
herausgreift, will auch nur deren Abgebrochenes sehen. Die
|
||
Ausschnitte (heute/überfällig) gelten dagegen nicht: Sie fragen
|
||
nach Fristen, und eine abgebrochene Aufgabe hat keine mehr. */
|
||
abgebrocheneZeigen(nurCreator
|
||
? alleAufgaben.filter((a) => a.creator_id === nurCreator)
|
||
: alleAufgaben);
|
||
}
|
||
|
||
function zeichne(aufgaben) {
|
||
const brett = $('brett');
|
||
brett.textContent = '';
|
||
brett.setAttribute('aria-busy', 'false');
|
||
|
||
for (const s of SPALTEN) {
|
||
const eigene = aufgaben.filter((a) => a.status === s.status);
|
||
const spalte = el('section', 'spalte');
|
||
spalte.dataset.status = s.status;
|
||
/* Die Karte merkt sich, wozu sie gehoert -- daraus findet der
|
||
Sprung vom Bericht die richtigen wieder, ohne die Liste ein
|
||
zweites Mal durchzugehen. */
|
||
|
||
const kopf = el('h2', 'spalte__kopf');
|
||
kopf.append(el('span', 'spalte__punkt'), el('span', null, s.name),
|
||
el('span', 'spalte__zahl', String(eigene.length)));
|
||
spalte.append(kopf);
|
||
/* Was diese Spalte bedeutet -- steht immer da, nicht nur wenn sie
|
||
leer ist. "Review" allein sagt einem Neuen nichts. */
|
||
spalte.append(el('p', 'spalte__unter', s.unter));
|
||
|
||
if (!eigene.length) spalte.append(el('p', 'spalte__leer', s.leer));
|
||
else for (const a of eigene) {
|
||
const k = karte(a);
|
||
k.dataset.status = a.status;
|
||
if (a.status !== 'erledigt' && a.frist && a.frist < heute) k.dataset.faellig = 'ja';
|
||
/* "Heute faellig" ist ein eigener Zustand, kein Sonderfall von
|
||
"ueberfaellig": Die Frist ist HEUTE und damit noch nicht
|
||
vorbei. Ohne dieses Merkmal koennte der Sprung von der
|
||
Startseite ("Heute faellig") nichts finden -- er zeigt dann
|
||
eine leere Auswahl, und man glaubt, man sei falsch
|
||
abgebogen. */
|
||
if (a.status !== 'erledigt' && a.frist === heute) k.dataset.heute = 'ja';
|
||
spalte.append(k);
|
||
}
|
||
|
||
brett.append(spalte);
|
||
}
|
||
}
|
||
|
||
/* ---------- Sprung vom Bericht -------------------------------------------
|
||
|
||
"wenn man auf die Kisten drueckt, sofort zu diesem Punkt
|
||
weitergeschickt werden" (01.09.2026).
|
||
|
||
Hier wird NICHT gefiltert, sondern hervorgehoben. Das Brett lebt
|
||
davon, dass man alle vier Spalten nebeneinander sieht -- wer nur die
|
||
ueberfaelligen zeigt, nimmt genau den Ueberblick weg, wegen dem man
|
||
hergekommen ist. */
|
||
/* ZWEI SCHLUESSEL KAMEN AM 09.09.2026 DAZU (screen1).
|
||
|
||
Filipe, mit Bild der sieben Zahlenkarten auf der Startseite: "wenn
|
||
ich auf die druecke soll ich auf zu denen punkten gebracht werden."
|
||
|
||
Fuenf der sieben konnten das schon (offen, arbeit, review,
|
||
erledigt, ueberfaellig) -- nur wusste es niemand, weil die Karten
|
||
keine Links waren. "Heute faellig" und "Abgebrochen" fehlten
|
||
ganz. */
|
||
const ZEIG_TEXT = {
|
||
offen: 'Aufgaben, die offen sind',
|
||
arbeit: 'Aufgaben, die gerade laufen',
|
||
review: 'Aufgaben, die auf Freigabe warten',
|
||
erledigt: 'Aufgaben, die erledigt sind',
|
||
ueberfaellig: 'Aufgaben, deren Frist vorbei ist',
|
||
heute: 'Aufgaben, die heute fällig sind',
|
||
abgebrochen: 'Aufgaben, die abgebrochen wurden',
|
||
};
|
||
|
||
function sprungZeigen() {
|
||
/* EINE EINZELNE AUFGABE, ueber `#a<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);
|
||
}
|
||
|
||
async function laden() {
|
||
try {
|
||
const antwort = await hole('/workspace/api/aufgaben');
|
||
if (!antwort.ok) { melde('Aufgaben konnten nicht geladen werden.'); return; }
|
||
const daten = await antwort.json();
|
||
alleAufgaben = daten.aufgaben || [];
|
||
aufwandUebernehmen(daten);
|
||
kategorien = daten.kategorien || [];
|
||
kanaele = daten.kanaele || [];
|
||
/* `null` muss `null` BLEIBEN -- ein `|| []` daraus zu machen waere
|
||
der stille Fehler: Aus "gilt immer" wuerde "gilt nie", und das
|
||
Feld verschwaende fuer genau die, die es brauchen. */
|
||
kategorieFuer = daten.kategorie_fuer === undefined ? [] : daten.kategorie_fuer;
|
||
kategorienEinsetzen();
|
||
kategorieFeldZeigen('f');
|
||
kanaeleEinsetzen();
|
||
kanalFeldZeigen('f');
|
||
kanalFeldZeigen('b');
|
||
lageZeigen(alleAufgaben);
|
||
neuZeichnen();
|
||
/* Erst NACH dem Zeichnen -- vorher gibt es die Karten nicht, und
|
||
der Sprung ginge ins Leere, ohne dass jemand etwas merkt. Nur
|
||
beim ersten Laden: Sonst springt die Seite nach jeder
|
||
Statusaenderung erneut weg. */
|
||
if (!gesprungen) { gesprungen = true; sprungZeigen(); }
|
||
} catch { /* bereits umgeleitet */ }
|
||
}
|
||
|
||
/* ---------- Neue Aufgabe -------------------------------------------- */
|
||
|
||
function formularUmschalten(auf) {
|
||
$('neu').hidden = !auf;
|
||
$('neu-oeffnen').hidden = auf;
|
||
if (auf) $('f-titel').focus();
|
||
}
|
||
|
||
$('neu-oeffnen').addEventListener('click', () => formularUmschalten(true));
|
||
$('neu-abbrechen').addEventListener('click', () => {
|
||
$('neu').reset(); $('neu-fehler').textContent = ''; formularUmschalten(false);
|
||
});
|
||
|
||
$('neu').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const knopfEl = $('neu-speichern');
|
||
$('neu-fehler').textContent = '';
|
||
|
||
const daten = {
|
||
titel: $('f-titel').value,
|
||
beschreibung: $('f-text').value,
|
||
prioritaet: $('f-prio').value,
|
||
frist: $('f-frist').value,
|
||
};
|
||
if (LEITUNG.has(ich.rolle)) {
|
||
daten.creator_id = $('f-creator').value || null;
|
||
daten.verantwortlich_id = $('f-verant').value || null;
|
||
daten.creator_extern = $('f-creator').dataset.extern || null;
|
||
daten.verantwortlich_extern = $('f-verant').dataset.extern || null;
|
||
}
|
||
if (!$('feld-kategorie')?.hidden) daten.kategorie = $('f-kategorie').value || '';
|
||
if (!$('feld-kanal')?.hidden) daten.kanal = $('f-kanal').value || '';
|
||
|
||
knopfEl.disabled = true;
|
||
try {
|
||
const antwort = await hole('/workspace/api/aufgaben', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(daten),
|
||
});
|
||
if (!antwort.ok) {
|
||
const d = await antwort.json().catch(() => ({}));
|
||
$('neu-fehler').textContent = 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 || '';
|
||
$('b-fehler').textContent = '';
|
||
if (LEITUNG.has(ich.rolle)) {
|
||
$('b-creator').value = a.creator_id ? String(a.creator_id) : '';
|
||
$('b-verant').value = a.verantwortlich_id ? String(a.verantwortlich_id) : '';
|
||
$('b-loeschen').hidden = false;
|
||
}
|
||
kategorieFeldZeigen('b');
|
||
if ($('b-kategorie')) $('b-kategorie').value = a.kategorie || '';
|
||
if ($('b-kanal')) $('b-kanal').value = a.kanal || '';
|
||
if ($('b-aufwand')) $('b-aufwand').value = a.aufwand || '';
|
||
window.anhaengeLaden?.(a.id);
|
||
dialog.showModal();
|
||
$('b-titel').focus();
|
||
}
|
||
|
||
$('b-abbrechen').addEventListener('click', () => dialog.close());
|
||
|
||
/* Der Abbruch-Dialog. Am submit des Formulars und nicht am Knopf:
|
||
So löst auch Enter im Textfeld aus, und Esc schließt von selbst
|
||
(beides bringt <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,
|
||
};
|
||
if (LEITUNG.has(ich.rolle)) {
|
||
daten.creator_id = $('b-creator').value || null;
|
||
daten.verantwortlich_id = $('b-verant').value || null;
|
||
daten.creator_extern = $('b-creator').dataset.extern || null;
|
||
daten.verantwortlich_extern = $('b-verant').dataset.extern || null;
|
||
}
|
||
if (!$('b-feld-kategorie')?.hidden) daten.kategorie = $('b-kategorie').value || '';
|
||
if (!$('b-feld-kanal')?.hidden) daten.kanal = $('b-kanal').value || '';
|
||
$('b-speichern').disabled = true;
|
||
try {
|
||
const a = await hole(`/workspace/api/aufgaben/${bearbeitet.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(daten),
|
||
});
|
||
if (!a.ok) {
|
||
$('b-fehler').textContent = sagWas((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 () => {
|
||
/* 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; }
|
||
dialog.close();
|
||
await laden();
|
||
} catch { /* umgeleitet */ }
|
||
});
|
||
|
||
/* ---------- Start ----------------------------------------------------- */
|
||
|
||
|
||
(async () => {
|
||
try {
|
||
const antwort = await hole('/workspace/api/ich');
|
||
if (!antwort.ok) { location.assign('/workspace/'); return; }
|
||
ich = await antwort.json();
|
||
} catch { return; }
|
||
|
||
window.werZeigen?.(ich);
|
||
|
||
if (LEITUNG.has(ich.rolle)) {
|
||
$('feld-creator').hidden = false;
|
||
$('feld-verant').hidden = false;
|
||
$('b-feld-creator').hidden = false;
|
||
$('b-feld-verant').hidden = false;
|
||
try {
|
||
const { personen } = await (await hole('/workspace/api/personen')).json();
|
||
/* AUCH ALS DATEN BEHALTEN, nicht nur als <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'));
|
||
}
|
||
for (const ziel of ['b-creator', 'b-verant']) {
|
||
$(ziel)?.addEventListener('change', () => kategorieFeldZeigen('b'));
|
||
}
|
||
}
|
||
|
||
await laden();
|
||
await vorlagenbrettZeigen();
|
||
})();
|
||
|
||
/* =====================================================================
|
||
AUFGABEN AUS DER VORLAGE
|
||
|
||
Vier Bereiche, vier Stufen, drei Aufgaben je Feld. Der Block ist
|
||
bewusst KEINE zweite Aufgabenverwaltung: Er legt eine ganz normale
|
||
Aufgabe an, mit Frist und Verantwortlichem, und verschwindet danach
|
||
aus dem Weg. Alles Weitere macht das Brett darunter.
|
||
|
||
WARUM DIE BEIDEN REIHEN DIESELBEN KNOEPFE SIND WIE IN DER
|
||
CHECKLISTE: Es sind dieselben vier Bereiche und dieselben vier
|
||
Stufen. Zwei verschiedene Darstellungen fuer dieselbe Einteilung
|
||
waeren zwei Sachen zum Lernen statt einer.
|
||
|
||
OHNE CREATOR KEINE AUFGABE. Ein Creator legt sie fuer sich an, ein
|
||
Betreuer fuer den, den er oben gewaehlt hat. Ist keiner gewaehlt,
|
||
steht das da -- statt eines Knopfes, der beim Druecken einen Fehler
|
||
zeigt.
|
||
===================================================================== */
|
||
const BEREICHE_VORLAGE = [
|
||
{ schluessel: 'live', name: 'LIVE-Analyse' },
|
||
{ schluessel: 'content', name: 'Content-Ideen' },
|
||
{ schluessel: 'technik', name: 'Technik' },
|
||
{ schluessel: 'community', name: 'Community' },
|
||
];
|
||
const STUFEN_VORLAGE = [
|
||
{ schluessel: 'anfaenger', name: 'Anfänger',
|
||
hilfe: 'Was von Anfang an sitzen muss. Meist an einem Abend zu schaffen.' },
|
||
{ schluessel: 'fort', name: 'Fortgeschritten',
|
||
hilfe: 'Aus Zufall wird Routine. Braucht Wiederholung, also Wochen.' },
|
||
{ schluessel: 'profi', name: 'Profi',
|
||
hilfe: 'Gemessen statt geschätzt: ein Wert, ein Versuch, ein Beleg.' },
|
||
{ schluessel: 'meister', name: 'Meister',
|
||
hilfe: 'Was auch ohne dich weiterläuft — Vorlage, Vertretung, Ablage.' },
|
||
];
|
||
let vorlagen = null;
|
||
/* Der zweite Katalog -- welcher es ist, sagt der Server. Hier steht
|
||
bewusst nur "katalog": Im Browser darf nichts stehen, woraus sich
|
||
schliessen laesst, fuer wen er gedacht ist. */
|
||
let katalog = null;
|
||
let kGruppe = null;
|
||
/* WELCHE STUFE. `null` heisst alle -- und das ist die Vorgabe:
|
||
Wer eine Kategorie aufmacht, will erst sehen, was es gibt. */
|
||
let kStufe = null;
|
||
/* An WEN die Aufgabe geht. `null` heisst: das Feld oben im Formular
|
||
entscheidet, so wie bisher. */
|
||
let kZiel = null;
|
||
let personenListe = [];
|
||
/* Getrennt vom Inhalt, weil "nichts bekommen" ein gueltiges Ergebnis
|
||
ist. Ohne dieses Merkmal fragte die Seite bei jedem Neuzeichnen
|
||
erneut nach -- und bekaeme jedes Mal wieder nichts. */
|
||
let vorlagenGeladen = false;
|
||
let vBereich = 'live';
|
||
let vStufe = 'anfaenger';
|
||
|
||
/* ---------- DAS VORLAGENBRETT IST ZUGEKLAPPT (09.09.2026) ------------
|
||
Filipe: "ich will nur dass du dich informierst und schaust was man
|
||
vielleicht noch besser machen koennte von der benutzung ... ich red
|
||
von den aufgaben. das system von den aufgaben, die bedienung."
|
||
|
||
Gemessen auf dem Handy: Der Vorlagenblock fuellte anderthalb
|
||
Bildschirme, BEVOR die erste eigene Aufgabe kam. Nach dem Ausbau
|
||
von Profi und Meister am selben Tag ist er noch laenger geworden.
|
||
|
||
Das ist die falsche Reihenfolge: Vorlagen holt man selten, das
|
||
Brett benutzt man jeden Tag. Der Block bleibt an seiner Stelle --
|
||
dort gehoert er hin, weil daraus Aufgaben auf das Brett darunter
|
||
wandern --, aber er ist jetzt zu. Wer ihn aufmacht, findet ihn
|
||
beim naechsten Mal wieder offen: Wer Vorlagen holt, holt meistens
|
||
mehrere. */
|
||
const VORLAGEN_OFFEN = 'dfw_vorlagenbrett_offen';
|
||
let vOffen = false;
|
||
try { vOffen = localStorage.getItem(VORLAGEN_OFFEN) === 'ja'; } catch { vOffen = false; }
|
||
|
||
async function vorlagenbrettZeigen() {
|
||
const ziel = $('vorlagenbrett');
|
||
if (!ziel || !ich) return;
|
||
/* WER DIESEN KATALOG BEKOMMT, ENTSCHEIDET DER SERVER.
|
||
|
||
Hier stand kurzzeitig ein Vergleich auf einen Rollennamen -- in
|
||
einer Datei, die JEDER bekommt, der die Seite oeffnet. Jetzt
|
||
liefert /workspace/api/vorlagen fuer die Betroffenen schlicht
|
||
nichts, und `if (!vorlagen) return` weiter unten laesst das Brett
|
||
verborgen. Dieselbe Wirkung, ohne eine Zeile, die verraet, fuer
|
||
wen sie gilt. */
|
||
if (!vorlagenGeladen) {
|
||
try {
|
||
const a = await hole('/workspace/api/vorlagen');
|
||
if (!a.ok) return;
|
||
const d = await a.json();
|
||
vorlagen = d.aufgaben || null;
|
||
/* Ein Katalog mit null Eintraegen ist keiner. Ohne diese Pruefung
|
||
haette ein leeres `{gruppen:[],aufgaben:[]}` einen Block mit
|
||
Ueberschrift und nichts darin ergeben -- das sieht aus wie ein
|
||
Fehler, und es waere auch einer. */
|
||
katalog = d.katalog && Array.isArray(d.katalog.aufgaben) && d.katalog.aufgaben.length
|
||
? d.katalog : null;
|
||
vorlagenGeladen = true;
|
||
} catch { return; }
|
||
}
|
||
/* Zwei Kataloge, einer je Person -- nie beide. Wer den einen
|
||
bekommt, bekommt den anderen nicht; das entscheidet der Server. */
|
||
if (katalog) { katalogBrett(ziel); return; }
|
||
if (!vorlagen) return;
|
||
ziel.hidden = false;
|
||
ziel.textContent = '';
|
||
|
||
/* DER KOPF IST DER KNOPF. Ein eigener kleiner Schalter daneben waere
|
||
ein zweites Ziel fuer dieselbe Absicht -- und ein kleineres. */
|
||
const kopf = el('button', 'vorlagenbrett__kopf');
|
||
kopf.type = 'button';
|
||
kopf.setAttribute('aria-expanded', String(vOffen));
|
||
kopf.setAttribute('aria-controls', 'vorlagenbrett-koerper');
|
||
const kText = el('span', 'vorlagenbrett__kopftext');
|
||
kText.append(el('span', 'feldschild', 'Aufgaben aus der Vorlage'));
|
||
/* Zugeklappt zaehlt nicht die Erklaerung, sondern WIE VIEL darin
|
||
liegt -- sonst sieht der Block aus wie eine Ueberschrift ohne
|
||
Inhalt und niemand macht ihn je auf. */
|
||
const gesamt = Object.values(vorlagen)
|
||
.reduce((n, b) => n + Object.values(b).reduce((m, l) => m + l.length, 0), 0);
|
||
kText.append(el('span', 'vorlagenbrett__unter', vOffen
|
||
? 'Vier Bereiche, vier Stufen. Übernommen wird als ganz normale Aufgabe '
|
||
+ '– mit Frist, veränderbar, auf dem Brett darunter.'
|
||
: `${gesamt} fertige Aufgaben in vier Bereichen und vier Stufen – zum Übernehmen.`));
|
||
kopf.append(kText);
|
||
const pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
pfeil.setAttribute('viewBox', '0 0 24 24');
|
||
pfeil.setAttribute('class', 'klapp-pfeil');
|
||
pfeil.setAttribute('aria-hidden', 'true');
|
||
const pfad = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
pfad.setAttribute('d', 'm6 9.5 6 6 6-6');
|
||
pfeil.append(pfad);
|
||
kopf.append(pfeil);
|
||
kopf.addEventListener('click', () => {
|
||
vOffen = !vOffen;
|
||
try { localStorage.setItem(VORLAGEN_OFFEN, vOffen ? 'ja' : 'nein'); } catch { /* egal */ }
|
||
vorlagenbrettZeigen();
|
||
});
|
||
ziel.append(kopf);
|
||
ziel.dataset.auf = vOffen ? 'ja' : 'nein';
|
||
|
||
/* Zugeklappt wird der Rest gar nicht erst gebaut. Ihn nur zu
|
||
verstecken hiesse, bei jedem Neuzeichnen achtzig Karten
|
||
anzulegen, die niemand sieht. */
|
||
if (!vOffen) return;
|
||
|
||
const koerper = el('div', 'vorlagenbrett__koerper');
|
||
koerper.id = 'vorlagenbrett-koerper';
|
||
ziel.append(koerper);
|
||
|
||
/* Reihe 1: der Bereich. */
|
||
const r1 = el('div', 'stufenleiter');
|
||
r1.setAttribute('role', 'group');
|
||
r1.setAttribute('aria-label', 'Bereich wählen');
|
||
for (const b of BEREICHE_VORLAGE) {
|
||
const k = el('button', 'stufenleiter__knopf');
|
||
k.type = 'button';
|
||
k.dataset.stufe = b.schluessel;
|
||
k.setAttribute('aria-pressed', String(vBereich === b.schluessel));
|
||
k.append(el('span', 'stufenleiter__name', b.name));
|
||
k.addEventListener('click', () => { vBereich = b.schluessel; vorlagenbrettZeigen(); });
|
||
r1.append(k);
|
||
}
|
||
koerper.append(r1);
|
||
|
||
/* Reihe 2: die Stufe, mit der Zahl der Aufgaben darin. */
|
||
const r2 = el('div', 'stufenleiter');
|
||
r2.setAttribute('role', 'group');
|
||
r2.setAttribute('aria-label', 'Könnensstufe wählen');
|
||
for (const st of STUFEN_VORLAGE) {
|
||
const n = ((vorlagen[vBereich] || {})[st.schluessel] || []).length;
|
||
if (!n) continue;
|
||
const k = el('button', 'stufenleiter__knopf');
|
||
k.type = 'button';
|
||
k.dataset.stufe = st.schluessel;
|
||
k.setAttribute('aria-pressed', String(vStufe === st.schluessel));
|
||
k.title = st.hilfe;
|
||
k.append(el('span', 'stufenleiter__name', st.name));
|
||
k.append(el('span', 'stufenleiter__zahl', String(n)));
|
||
k.addEventListener('click', () => { vStufe = st.schluessel; vorlagenbrettZeigen(); });
|
||
r2.append(k);
|
||
}
|
||
koerper.append(r2);
|
||
const hilfe = STUFEN_VORLAGE.find((x) => x.schluessel === vStufe)?.hilfe;
|
||
if (hilfe) koerper.append(el('p', 'stufenleiter__hilfe', hilfe));
|
||
|
||
/* Für wen? */
|
||
const fuer = ich.rolle === 'creator' ? ich.id : nurCreator;
|
||
if (!fuer) {
|
||
koerper.append(el('p', 'vorlagenbrett__hinweis',
|
||
'Wähle oben einen Creator, dann lassen sich diese Aufgaben übernehmen.'));
|
||
}
|
||
|
||
/* WAS SCHON GEHOLT WURDE, STEHT DA (09.09.2026).
|
||
|
||
Vorher konnte man dieselbe Vorlage zweimal uebernehmen und
|
||
bekam die Aufgabe zweimal auf das Brett -- ohne jeden Hinweis.
|
||
Bei achtzig Vorlagen ueber vier Stufen weiss niemand auswendig,
|
||
was er letzten Monat schon geholt hat.
|
||
|
||
Verglichen wird ueber die KENNUNG der Vorlage, nicht ueber den
|
||
Titel: Ein Titel darf geaendert werden, und dann waere der
|
||
Vergleich still falsch. Die Kennung liegt seit heute an der
|
||
Aufgabe (Spalte `vorlage`).
|
||
|
||
Es braucht dafuer keine zusaetzliche Abfrage -- die Aufgaben
|
||
liegen ohnehin schon im Browser. */
|
||
const schonDa = new Map();
|
||
for (const a of alleAufgaben) {
|
||
if (!a.vorlage) continue;
|
||
if (fuer && a.creator_id !== fuer) continue;
|
||
schonDa.set(a.vorlage, a);
|
||
}
|
||
|
||
const liste = (vorlagen[vBereich] || {})[vStufe] || [];
|
||
const box = el('div', 'vorlagenbrett__liste');
|
||
for (const [nr, v] of liste.entries()) {
|
||
const karte = el('div', 'vorlagenbrett__karte');
|
||
const schon = schonDa.get(v.schluessel);
|
||
if (schon) karte.dataset.schon = 'ja';
|
||
karte.append(el('h3', 'vorlagenbrett__titel', v.titel));
|
||
karte.append(el('p', 'vorlagenbrett__text', v.text));
|
||
const fuss = el('div', 'vorlagenbrett__fuss');
|
||
fuss.append(el('span', 'vorlagenbrett__frist',
|
||
v.tage === 1 ? 'Frist: morgen' : `Frist: in ${v.tage} Tagen`));
|
||
if (schon) {
|
||
/* Der Zustand steht dabei, nicht nur die Tatsache. "Schon
|
||
uebernommen" allein liesse offen, ob sie noch offen ist oder
|
||
laengst abgehakt -- und danach entscheidet man, ob man sie
|
||
ein zweites Mal holt. */
|
||
fuss.append(el('span', 'vorlagenbrett__schon',
|
||
`schon übernommen · ${STATUS_NAME[schon.status] || schon.status}`));
|
||
}
|
||
if (fuer) {
|
||
const k = el('button', 'knopf knopf--klein', schon ? 'Nochmal' : 'Übernehmen');
|
||
k.type = 'button';
|
||
if (schon) k.title = 'Diese Vorlage liegt schon auf dem Brett – noch einmal übernehmen?';
|
||
k.addEventListener('click', () => uebernehmen(k, { art: 'aufgabe', nr }, fuer));
|
||
fuss.append(k);
|
||
}
|
||
karte.append(fuss);
|
||
box.append(karte);
|
||
}
|
||
/* Und wie viele der Stufe noch offen sind -- die Zahl, nach der man
|
||
eigentlich sucht, wenn man den Block aufmacht. */
|
||
if (liste.length) {
|
||
const offen = liste.filter((v) => !schonDa.has(v.schluessel)).length;
|
||
koerper.append(el('p', 'vorlagenbrett__stand', offen
|
||
? `${offen} von ${liste.length} noch nicht übernommen.`
|
||
: `Alle ${liste.length} dieser Stufe sind übernommen.`));
|
||
}
|
||
koerper.append(box);
|
||
|
||
if (fuer && liste.length > 1) {
|
||
const alle = el('button', 'abmelden', `Alle ${liste.length} übernehmen`);
|
||
alle.title = 'Legt alle Aufgaben dieser Stufe an – auch die, die schon auf dem Brett liegen.';
|
||
alle.type = 'button';
|
||
alle.style.marginTop = '12px';
|
||
alle.addEventListener('click', () => uebernehmen(alle, { art: 'aufgaben_alle' }, fuer));
|
||
koerper.append(alle);
|
||
}
|
||
}
|
||
|
||
|
||
/* =====================================================================
|
||
DAS ZWEITE VORLAGENBRETT (10.09.2026)
|
||
|
||
Derselbe Block, derselbe Kopf, dieselben Karten -- nur eine andere
|
||
Einteilung: neun Etappen statt vier Bereichen mal vier Stufen. Die
|
||
Bausteine sind bewusst dieselben (vorlagenbrett__karte,
|
||
stufenleiter, ...): Ein zweites Aussehen fuer dieselbe Sache waere
|
||
zwei Dinge zum Lernen statt einem -- und eine zweite Stelle, an der
|
||
jemand eine neue CSS-Klasse in sieben Kopien pflegen muesste.
|
||
|
||
WAS DRINSTEHT, KOMMT VOM SERVER, samt Gruppennamen und Texten. In
|
||
dieser Datei steht kein Wort darueber, fuer wen dieser Katalog ist.
|
||
===================================================================== */
|
||
function katalogBrett(ziel) {
|
||
ziel.hidden = false;
|
||
ziel.textContent = '';
|
||
if (!kGruppe) kGruppe = (katalog.gruppen[0] || {}).schluessel || null;
|
||
|
||
const kopf = el('button', 'vorlagenbrett__kopf');
|
||
kopf.type = 'button';
|
||
kopf.setAttribute('aria-expanded', String(vOffen));
|
||
kopf.setAttribute('aria-controls', 'vorlagenbrett-koerper');
|
||
const kText = el('span', 'vorlagenbrett__kopftext');
|
||
kText.append(el('span', 'feldschild', 'Aufgaben aus der Vorlage'));
|
||
/* DER SATZ MUSS SAGEN, WONACH SORTIERT IST. Bis zum 16.09.2026 stand
|
||
hier "Nach Etappen sortiert" -- und seit dem Umbau auf Kategorie
|
||
mal Stufe war das schlicht falsch. Ein Satz, der etwas anderes
|
||
beschreibt als das, was darunter steht, kostet mehr Zeit als er
|
||
spart. */
|
||
kText.append(el('span', 'vorlagenbrett__unter', vOffen
|
||
? 'Nach Kategorie und Stufe sortiert. Übernommen wird als ganz normale '
|
||
+ 'Aufgabe – mit Frist und Kategorie, veränderbar, auf dem Brett darunter.'
|
||
: `${katalog.aufgaben.length} fertige Aufgaben in `
|
||
+ `${katalog.gruppen.length} Kategorien – zum Übernehmen.`));
|
||
kopf.append(kText);
|
||
const pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
pfeil.setAttribute('viewBox', '0 0 24 24');
|
||
pfeil.setAttribute('class', 'klapp-pfeil');
|
||
pfeil.setAttribute('aria-hidden', 'true');
|
||
const pfad = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
pfad.setAttribute('d', 'm6 9.5 6 6 6-6');
|
||
pfeil.append(pfad);
|
||
kopf.append(pfeil);
|
||
kopf.addEventListener('click', () => {
|
||
vOffen = !vOffen;
|
||
try { localStorage.setItem(VORLAGEN_OFFEN, vOffen ? 'ja' : 'nein'); } catch { /* egal */ }
|
||
vorlagenbrettZeigen();
|
||
});
|
||
ziel.append(kopf);
|
||
ziel.dataset.auf = vOffen ? 'ja' : 'nein';
|
||
/* Zugeklappt wird der Rest gar nicht erst gebaut -- sonst lägen bei
|
||
jedem Neuzeichnen sechzig Karten da, die niemand sieht. */
|
||
if (!vOffen) return;
|
||
|
||
const koerper = el('div', 'vorlagenbrett__koerper');
|
||
koerper.id = 'vorlagenbrett-koerper';
|
||
ziel.append(koerper);
|
||
|
||
/* Die Etappen als Reihe -- mit der Zahl darin, damit man sieht, wo
|
||
etwas liegt, bevor man klickt. */
|
||
const reihe = el('div', 'stufenleiter');
|
||
reihe.setAttribute('role', 'group');
|
||
reihe.setAttribute('aria-label', 'Kategorie wählen');
|
||
for (const g of katalog.gruppen) {
|
||
const n = katalog.aufgaben.filter((v) => v.gruppe === g.schluessel).length;
|
||
if (!n) continue;
|
||
const k = el('button', 'stufenleiter__knopf');
|
||
k.type = 'button';
|
||
k.dataset.stufe = g.schluessel;
|
||
k.setAttribute('aria-pressed', String(kGruppe === g.schluessel));
|
||
k.append(el('span', 'stufenleiter__name', g.name));
|
||
k.append(el('span', 'stufenleiter__zahl', String(n)));
|
||
k.addEventListener('click', () => { kGruppe = g.schluessel; vorlagenbrettZeigen(); });
|
||
reihe.append(k);
|
||
}
|
||
koerper.append(reihe);
|
||
|
||
/* DERSELBE BAUSTEIN WIE BEIM ANDEREN BRETT (16.09.2026).
|
||
|
||
Filipe: "ich will dass es vom aussehen auch genau gleich
|
||
aussieht und so geil wie bei den anderen kategorien."
|
||
|
||
Hier stand `vorlagenbrett__stand` -- das ist die Zeile fuer
|
||
ZAHLEN ("3 von 11 noch nicht uebernommen"), kleiner und mit
|
||
Ziffern in Tabellenbreite. Die Erklaerung unter der Auswahl
|
||
heisst drueben `stufenleiter__hilfe` und sieht anders aus.
|
||
Zwei Bretter, die dasselbe tun und verschieden aussehen, wirken
|
||
wie zwei verschiedene Sachen. */
|
||
const gruppe = katalog.gruppen.find((g) => g.schluessel === kGruppe);
|
||
if (gruppe?.text) koerper.append(el('p', 'stufenleiter__hilfe', gruppe.text));
|
||
|
||
/* ---- Die Stufe (16.09.2026) ---------------------------------------
|
||
|
||
Filipe: "wie bei den aufgaben wo sie für mich haben auch mit
|
||
kategorien und vielen aufgaben."
|
||
|
||
Dieselbe Kategorie bedeutet für jemanden in der ersten Woche
|
||
etwas anderes als für jemanden, der einarbeitet. Eine Aufgabe
|
||
der Stufe "erfahren" an einen Neuen zu geben ist kein
|
||
Kompliment, sondern ein Überfallen.
|
||
|
||
DIE STUFEN KOMMEN VOM SERVER, nicht aus einer Liste hier: Eine
|
||
zweite Liste im Browser wäre die, die beim nächsten Zusatz
|
||
veraltet -- und ausgeliefert wird sie an jeden. */
|
||
const inGruppe = katalog.aufgaben.filter((v) => v.gruppe === kGruppe);
|
||
if (Array.isArray(katalog.stufen) && katalog.stufen.length) {
|
||
/* VOLLE GROESSE, nicht `--fein`: Drueben ist die zweite Reihe
|
||
(die Stufen) genauso gross wie die erste. */
|
||
const sr = el('div', 'stufenleiter');
|
||
sr.setAttribute('role', 'group');
|
||
sr.setAttribute('aria-label', 'Stufe wählen');
|
||
const alle = el('button', 'stufenleiter__knopf');
|
||
alle.type = 'button';
|
||
alle.setAttribute('aria-pressed', String(kStufe === null));
|
||
alle.append(el('span', 'stufenleiter__name', 'Alle'));
|
||
alle.append(el('span', 'stufenleiter__zahl', String(inGruppe.length)));
|
||
alle.addEventListener('click', () => { kStufe = null; vorlagenbrettZeigen(); });
|
||
sr.append(alle);
|
||
for (const st of katalog.stufen) {
|
||
const n = inGruppe.filter((v) => v.stufe === st.schluessel).length;
|
||
if (!n) continue;
|
||
const k = el('button', 'stufenleiter__knopf');
|
||
k.type = 'button';
|
||
k.dataset.stufe = st.schluessel;
|
||
k.setAttribute('aria-pressed', String(kStufe === st.schluessel));
|
||
k.title = st.text || '';
|
||
k.append(el('span', 'stufenleiter__name', st.name));
|
||
k.append(el('span', 'stufenleiter__zahl', String(n)));
|
||
k.addEventListener('click', () => { kStufe = st.schluessel; vorlagenbrettZeigen(); });
|
||
sr.append(k);
|
||
}
|
||
koerper.append(sr);
|
||
const gewaehlt = katalog.stufen.find((x) => x.schluessel === kStufe);
|
||
if (gewaehlt?.text) koerper.append(el('p', 'stufenleiter__hilfe', gewaehlt.text));
|
||
}
|
||
|
||
/* Was schon auf dem Brett liegt -- aus den Aufgaben, die ohnehin
|
||
geladen sind. Verglichen wird die Kennung der Vorlage und nicht
|
||
der Titel: Der darf geändert werden. */
|
||
const schonDa = new Map();
|
||
for (const a of alleAufgaben) if (a.vorlage) schonDa.set(a.vorlage, a);
|
||
|
||
/* ---- AN WEN? Mit der Last daneben (16.09.2026) -------------------
|
||
|
||
Bis heute kam die Person aus dem Formular GANZ OBEN auf der
|
||
Seite: Man wählt sie dort, scrollt herunter und drückt hier
|
||
„Übernehmen". Wer das nicht weiß, bekommt „Bitte zuerst eine
|
||
Person wählen" und sucht.
|
||
|
||
UND DAS WICHTIGERE: Man sah beim Vergeben nicht, wer schon wie
|
||
viel hat. Zu wenig Zeit ist in den Untersuchungen zu
|
||
Moderatoren der meistgenannte Grund fürs Aufhören — und die
|
||
Person, die Aufgaben verteilt, ist die einzige, die das
|
||
verhindern kann. Dafür muss die Zahl dort stehen, wo entschieden
|
||
wird, nicht auf einer Auswertung, die man hinterher aufruft.
|
||
|
||
KEINE SCHWELLE, KEINE WARNFARBE. Was „zu viel" ist, hängt vom
|
||
Menschen ab; eine feste Grenze wäre geraten. Was NICHT geraten
|
||
ist: dass etwas überfällig liegt. Nur das wird markiert. */
|
||
/* WER IN FRAGE KOMMT, SAGT DER SERVER (17.09.2026).
|
||
|
||
Hier standen zwei Rollennamen. Diese Datei ist ohne Anmeldung
|
||
aus dem offenen Netz lesbar -- damit war der verborgene Zugang
|
||
darin nachzulesen. Gefunden von der Wortleck-Pruefung, nicht vom
|
||
Auge. */
|
||
const zielbar = personenListe.filter((p) => p.fuer_katalog);
|
||
if (zielbar.length && $('feld-verant') && !$('feld-verant').hidden) {
|
||
/* DIE DRITTE REIHE IST ETWAS ANDERES als die zwei darueber: Sie
|
||
waehlt keinen Ausschnitt des Katalogs, sondern einen MENSCHEN.
|
||
Deshalb bleibt sie etwas kleiner -- gleich aussehende Reihen,
|
||
die Verschiedenes tun, sind schlechter als zwei, die man
|
||
unterscheiden kann. */
|
||
koerper.append(el('p', 'feldschild', 'An wen'));
|
||
const wer = el('div', 'stufenleiter stufenleiter--wer');
|
||
wer.setAttribute('role', 'group');
|
||
wer.setAttribute('aria-label', 'Person wählen');
|
||
for (const p of zielbar) {
|
||
const seine = alleAufgaben.filter((a) => a.verantwortlich_id === p.id);
|
||
const offenN = seine.filter(istOffen).length;
|
||
const spaet = seine.filter(istFaellig).length;
|
||
const k = el('button', 'stufenleiter__knopf');
|
||
k.type = 'button';
|
||
if (spaet) k.dataset.spaet = 'ja';
|
||
k.setAttribute('aria-pressed', String(String(kZiel) === String(p.id)));
|
||
k.title = spaet
|
||
? `${p.name}: ${offenN} offen, davon ${spaet} überfällig`
|
||
: `${p.name}: ${offenN} offen`;
|
||
k.append(el('span', 'stufenleiter__name', p.name));
|
||
k.append(el('span', 'stufenleiter__zahl', String(offenN)));
|
||
if (spaet) k.append(el('span', 'stufenleiter__spaet', `${spaet} spät`));
|
||
k.addEventListener('click', () => {
|
||
kZiel = String(kZiel) === String(p.id) ? null : String(p.id);
|
||
vorlagenbrettZeigen();
|
||
});
|
||
wer.append(k);
|
||
}
|
||
koerper.append(wer);
|
||
const gewaehlt = zielbar.find((p) => String(p.id) === String(kZiel));
|
||
koerper.append(el('p', 'stufenleiter__hilfe', gewaehlt
|
||
? `Übernommene Aufgaben gehen an ${gewaehlt.name}.`
|
||
: 'Niemand gewählt – es gilt die Person aus dem Formular oben.'));
|
||
}
|
||
|
||
const liste = katalog.aufgaben
|
||
.map((v, nr) => ({ ...v, nr }))
|
||
.filter((v) => v.gruppe === kGruppe)
|
||
.filter((v) => !kStufe || v.stufe === kStufe);
|
||
const box = el('div', 'vorlagenbrett__liste');
|
||
for (const v of liste) {
|
||
const karte = el('div', 'vorlagenbrett__karte');
|
||
const schon = schonDa.get(v.schluessel);
|
||
if (schon) karte.dataset.schon = 'ja';
|
||
karte.append(el('h3', 'vorlagenbrett__titel', v.titel));
|
||
karte.append(el('p', 'vorlagenbrett__text', v.text));
|
||
const fuss = el('div', 'vorlagenbrett__fuss');
|
||
fuss.append(el('span', 'vorlagenbrett__frist',
|
||
v.tage === 1 ? 'Frist: morgen' : `Frist: in ${v.tage} Tagen`));
|
||
/* Die empfohlene Rolle steht im Dokument neben jeder Aufgabe und
|
||
ist die halbe Auskunft: "Technik-Check" heisst etwas anderes,
|
||
je nachdem, wer ihn macht. */
|
||
if (v.rolle) fuss.append(el('span', 'vorlagenbrett__frist', `für: ${v.rolle}`));
|
||
if (schon) {
|
||
fuss.append(el('span', 'vorlagenbrett__schon',
|
||
`schon übernommen · ${STATUS_NAME[schon.status] || schon.status}`));
|
||
}
|
||
const k = el('button', 'knopf knopf--klein', schon ? 'Nochmal' : 'Übernehmen');
|
||
k.type = 'button';
|
||
if (schon) k.title = 'Diese Vorlage liegt schon auf dem Brett – noch einmal übernehmen?';
|
||
k.addEventListener('click', () => uebernehmen(k,
|
||
{ art: 'katalog', nr: v.nr, gruppe: kGruppe, person_id: katalogZiel() }));
|
||
fuss.append(k);
|
||
karte.append(fuss);
|
||
box.append(karte);
|
||
}
|
||
|
||
if (liste.length) {
|
||
const offen = liste.filter((v) => !schonDa.has(v.schluessel)).length;
|
||
koerper.append(el('p', 'vorlagenbrett__stand', offen
|
||
? `${offen} von ${liste.length} noch nicht übernommen.`
|
||
: `Alle ${liste.length} davon sind übernommen.`));
|
||
}
|
||
koerper.append(box);
|
||
|
||
if (liste.length > 1) {
|
||
const alle = el('button', 'abmelden', `Alle ${liste.length} übernehmen`);
|
||
alle.title = 'Legt alle Aufgaben an, die gerade in der Liste stehen – '
|
||
+ 'auch die, die schon auf dem Brett liegen.';
|
||
alle.type = 'button';
|
||
alle.style.marginTop = '12px';
|
||
/* DIE STUFE WANDERT MIT (16.09.2026). Ohne sie legte dieser Knopf
|
||
alle Aufgaben der Kategorie an -- auch die, die gerade
|
||
ausgeblendet sind. Der Knopf sagt "Alle 4 übernehmen" und
|
||
legte elf an; das merkt man erst auf dem Brett. */
|
||
alle.addEventListener('click', () => uebernehmen(alle,
|
||
{ art: 'katalog_alle', gruppe: kGruppe, stufe: kStufe || '',
|
||
person_id: katalogZiel() }));
|
||
koerper.append(alle);
|
||
}
|
||
}
|
||
|
||
/* FUER WEN wird uebernommen? Wer nur eigene Aufgaben hat, meint sich
|
||
selbst -- der Server setzt das dann ein und braucht hier nichts.
|
||
Wer fuer andere eintragen darf, muss die Person oben ausgewaehlt
|
||
haben; sonst sagt der Server es. */
|
||
function katalogZiel() {
|
||
/* Die Auswahl im Vorlagenbrett geht vor -- sie steht dort, wo man
|
||
gerade hinsieht. Das Formular oben bleibt als Rueckfall, damit
|
||
der bisherige Weg weiter funktioniert. */
|
||
return kZiel || $('f-verant')?.value || $('f-creator')?.value || null;
|
||
}
|
||
|
||
/* Ein Weg fuer beide Knoepfe -- einzeln und alle. Zwei fast gleiche
|
||
Funktionen waeren der sichere Weg dazu, dass die eine irgendwann
|
||
anders reagiert als die andere. */
|
||
async function uebernehmen(knopf, was, fuer) {
|
||
knopf.disabled = true;
|
||
const vorher = knopf.textContent;
|
||
knopf.textContent = 'Wird übernommen …';
|
||
try {
|
||
const a = await hole('/workspace/api/vorlagen/uebernehmen', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ...was, bereich: vBereich, stufe: vStufe, creator_id: fuer }),
|
||
});
|
||
if (!a.ok) {
|
||
const f = await a.json().catch(() => ({}));
|
||
melde(sagWas(f.fehler, 'Das hat nicht geklappt.'));
|
||
knopf.disabled = false; knopf.textContent = vorher;
|
||
return;
|
||
}
|
||
melde('');
|
||
/* Erst das Brett neu laden, dann den Block -- so sieht man die
|
||
neue Aufgabe sofort dort, wo sie ab jetzt lebt. */
|
||
await laden();
|
||
await vorlagenbrettZeigen();
|
||
} catch {
|
||
melde('Das hat nicht geklappt.');
|
||
knopf.disabled = false; knopf.textContent = vorher;
|
||
}
|
||
}
|
||
})();
|
||
|
||
/* =====================================================================
|
||
ANHAENGE AN EINER AUFGABE (15.09.2026, Stufe 9)
|
||
|
||
KEIN ZWEITER HOCHLADEWEG. Die Datei geht an denselben Weg wie in der
|
||
Dateiablage (/workspace/api/dateien) und bekommt nur einen Kopf mehr:
|
||
die Nummer der Aufgabe. Sichtbarkeit, Freigaben, Fassungen und
|
||
Protokoll bleiben damit genau dort, wo sie schon richtig sind.
|
||
|
||
WAS BEIM LOESCHEN DER AUFGABE PASSIERT, steht in der Datenbank
|
||
(ON DELETE SET NULL) und nicht hier: Die Datei bleibt, sie verliert
|
||
nur ihren Bezug. Wer eine Aufgabe loescht, will die Aufgabe loeschen.
|
||
===================================================================== */
|
||
(() => {
|
||
const $ = (id) => document.getElementById(id);
|
||
const el = (t, k, text) => {
|
||
const n = document.createElement(t);
|
||
if (k) n.className = k;
|
||
if (text !== undefined) n.textContent = text;
|
||
return n;
|
||
};
|
||
const feld = $('b-datei');
|
||
const liste = $('b-anhaenge');
|
||
const stand = $('b-anhang-stand');
|
||
if (!feld || !liste) return;
|
||
|
||
let fuerAufgabe = null;
|
||
|
||
const groesse = (n) => (n >= 1048576
|
||
? (n / 1048576).toFixed(1) + ' MB'
|
||
: Math.max(1, Math.round(n / 1024)) + ' KB');
|
||
|
||
const sagen = (t) => { if (stand) stand.textContent = t || ''; };
|
||
|
||
window.anhaengeLaden = async function (aufgabeId) {
|
||
fuerAufgabe = aufgabeId;
|
||
liste.textContent = '';
|
||
sagen('');
|
||
try {
|
||
const a = await fetch('/workspace/api/aufgaben/' + aufgabeId + '/dateien',
|
||
{ credentials: 'same-origin' });
|
||
if (!a.ok) return;
|
||
const d = await a.json();
|
||
if (!d.dateien?.length) {
|
||
liste.append(el('span', 'anhaenge__leer', 'Noch nichts angehängt.'));
|
||
return;
|
||
}
|
||
for (const x of d.dateien) {
|
||
const v = el('a', 'anhaenge__datei');
|
||
/* Der Herunterlade-Weg heisst /inhalt -- /dateien/:id allein
|
||
gibt es nicht. Beim ersten Anlauf stand hier der Verweis ins
|
||
Leere; gefunden hat es der Blick in die Routen, nicht der
|
||
Klick. */
|
||
v.href = '/workspace/api/dateien/' + x.id + '/inhalt';
|
||
v.append(el('span', 'anhaenge__name', x.name_original));
|
||
v.append(el('span', 'anhaenge__groesse', groesse(x.groesse)));
|
||
/* Eine ueberholte Fassung ALS SOLCHE kennzeichnen -- sonst laedt
|
||
jemand hier die alte herunter und merkt es nicht. Genau der
|
||
Fall, fuer den es die Fassungen gibt. */
|
||
if (x.ersetzt_durch) {
|
||
const w = el('span', 'anhaenge__alt', 'nicht mehr aktuell');
|
||
w.title = 'Es gibt eine neuere Fassung dieser Datei.';
|
||
v.append(w);
|
||
} else if (x.fassung > 1) {
|
||
v.append(el('span', 'anhaenge__fassung', 'Fassung ' + x.fassung));
|
||
}
|
||
liste.append(v);
|
||
}
|
||
} catch { /* der Dialog ist auch ohne Anhaenge benutzbar */ }
|
||
};
|
||
|
||
feld.addEventListener('change', async () => {
|
||
const f = feld.files?.[0];
|
||
if (!f || !fuerAufgabe) return;
|
||
sagen('Wird hochgeladen …');
|
||
try {
|
||
const a = await fetch('/workspace/api/dateien', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
'Content-Type': f.type || 'application/octet-stream',
|
||
'x-dateiname': encodeURIComponent(f.name),
|
||
'x-aufgabe': String(fuerAufgabe),
|
||
},
|
||
body: f,
|
||
});
|
||
if (!a.ok) {
|
||
const j = await a.json().catch(() => ({}));
|
||
sagen(j.fehler === 'zu_gross'
|
||
? 'Die Datei ist zu groß.'
|
||
: (sagWas(j.fehler, 'Das hat nicht geklappt.')));
|
||
return;
|
||
}
|
||
sagen('Angehängt.');
|
||
await window.anhaengeLaden(fuerAufgabe);
|
||
} catch {
|
||
sagen('Keine Verbindung.');
|
||
} finally {
|
||
/* Zuruecksetzen, sonst loest dieselbe Datei kein zweites Mal aus. */
|
||
feld.value = '';
|
||
}
|
||
});
|
||
})();
|
||
|
||
/* =====================================================================
|
||
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;
|
||
};
|
||
})();
|