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]>
2247 lines
97 KiB
JavaScript
2247 lines
97 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 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);
|
||
}
|
||
})();
|