ZWEI WUENSCHE, EINE URSACHE: beide gehen auf den Block "Noch jemanden dazunehmen" zurueck, der am 21.09. ins Aufgabenformular kam. 1. "DIE ZWEI BUTTONS SOLLEN NICHT VOR DEM TEXT STEHEN" Gemessen bei 1280 px: "Anlegen" und "Abbrechen" lagen ueber zwei Hinweisen, mit 363x27 und 363x10 Pixeln Ueberlappung. Die Ursache ist eine Regel vom 17.09., und sie ist richtig: Der Hinweis haengt ABSOLUT unter seinem Feld, damit die Eingaben auf einer Linie bleiben. Was aus dem Fluss genommen wird, belegt aber keinen Platz -- solange darunter nur der Rasterabstand kam, fiel das nicht auf. Mit dem neuen Knopf wurde die Zelle hoeher, und der Hinweis wanderte mit, direkt auf die Knoepfe. Jetzt bekommt das Raster unter sich 44 px, wenn es einen haengenden Hinweis gibt (`:has()`, nicht pauschal -- ein Formular ohne Hinweis bekaeme sonst Leere geschenkt). Die Hoehe ist gemessen: ein Hinweis ist zweizeilig 35 px hoch plus 4 px Abstand. UND EIN ZWEITER FUND AN DERSELBEN STELLE: Der Block sass IN der Zelle "Wer macht es?". Dadurch stand deren Eingabe bei 780..824, die Nachbarin "Fuer welchen Kanal?" bei 833..877 -- 53 Pixel Versatz, und genau das sieht man als "verzogen". Er steht jetzt als eigene Rasterzeile hinter beiden; danach sind sie wieder buendig. Als eigene Zeile ist er ausserdem ehrlicher: "Noch jemanden dazunehmen" ist ein zweiter Schritt, kein Teil des ersten. Gefunden hat beides pruef-formulare, die seit dem 07.09. auf buendige Unterkanten prueft und seit dem 21.09. rot war. 2. "BEI EURE AUFGABEN DIE AUFGABEN VERTEILEN" Filipe, zum wiederholten Mal -- und so stand es auch im Auftrag vom 21.09. (Abschnitt 2). Auf "Eure Aufgaben" steht jetzt ein Band "Aufgabe verteilen". Es nimmt die gerade gewaehlte Person mit: "Aufgabe fuer Kessi" fuehrt auf das Aufgabenbrett, oeffnet das Formular und traegt sie ein. EIN WEG, KEIN ZWEITES FORMULAR. Zwei Formulare fuer dieselbe Sache waeren zwei Gelegenheiten, eines zu vergessen -- und man wuesste nie, welches das richtige ist. Beim Bauen gemessen: Das Band blieb unsichtbar, bis man eine Person anklickte -- `window.__ich` kommt ueber das Netz und steht beim ersten Aufruf noch nicht. Jetzt wird darauf gewartet, laengstens drei Sekunden, statt eine Zahl aus dem Kopf zu setzen. Zwei Pruefungen waren selbst kaputt: pruef-formulare zaehlte ein Eingabefeld mit, das in einem zugeklappten Block liegt und Hoehe 0 hat -- ein Feld, das man nicht sieht, kann nicht schief stehen. pruef-entwicklung verlangte, dass ein Modi "Eure Aufgaben" NICHT bekommt; das hat Filipe heute umgedreht. Geprueft: pruef-formulare 19/0 (war 18 ok / 1 FEHL), pruef-entwicklung 48/0 (war 45/1), pruef-aufgabenbrett 49/0, pruef-css-klassen 30/0. Der ganze Weg am Bildschirm gemessen: Band sichtbar, Person mitgenommen, Formular offen, richtige Person gewaehlt, keine Skriptfehler.
2118 lines
90 KiB
JavaScript
2118 lines
90 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();
|
||
}
|
||
|
||
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. */
|
||
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 */
|
||
let alleAufgaben = [];
|
||
|
||
/* Abgebrochenes zählt NICHT als offen (05.09.2026). Ohne diese
|
||
Ergänzung stünde eine abgebrochene Aufgabe weiter in "3 Aufgaben
|
||
stehen an" und in "Überfällig" -- und würde als etwas gemahnt, das
|
||
ausdrücklich niemand mehr tun soll. */
|
||
const istOffen = (a) => a.status !== 'erledigt' && a.status !== 'abgebrochen';
|
||
const istFaellig = (a) => istOffen(a) && a.frist && a.frist < heute;
|
||
const istHeute = (a) => istOffen(a) && a.frist === heute;
|
||
const istMeine = (a) => a.verantwortlich_id === ich.id;
|
||
|
||
function passt(a) {
|
||
if (nurCreator && a.creator_id !== nurCreator) return false;
|
||
if (ausschnitt === 'meine') return istMeine(a);
|
||
if (ausschnitt === 'faellig') return istFaellig(a);
|
||
if (ausschnitt === 'heute') return istHeute(a);
|
||
return true;
|
||
}
|
||
|
||
function lageZeigen(alle) {
|
||
const ziel = $('lage');
|
||
if (!ziel) return;
|
||
const faellig = alle.filter(istFaellig).length;
|
||
const heuteN = alle.filter(istHeute).length;
|
||
const offen = alle.filter(istOffen).length;
|
||
const meine = alle.filter((a) => istOffen(a) && istMeine(a)).length;
|
||
|
||
ziel.textContent = '';
|
||
let satz, stufe;
|
||
if (faellig) {
|
||
satz = `${faellig} ${faellig === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} überfällig – das zuerst.`;
|
||
stufe = 'warnung';
|
||
} else if (heuteN) {
|
||
satz = `${heuteN} ${heuteN === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} heute fällig.`;
|
||
stufe = 'offen';
|
||
} else if (offen) {
|
||
satz = `${offen} ${offen === 1 ? 'Aufgabe steht' : 'Aufgaben stehen'} an, nichts davon brennt.`;
|
||
stufe = 'offen';
|
||
} else {
|
||
satz = 'Nichts offen – alles abgearbeitet.';
|
||
stufe = 'ruhig';
|
||
}
|
||
ziel.dataset.stufe = stufe;
|
||
ziel.append(el('span', 'lage__satz', satz));
|
||
/* Die eigene Zahl steht daneben, nicht im Satz: Auf einem Brett, das
|
||
auch fremde Aufgaben zeigt, ist "wie viel davon ist meins" die
|
||
zweite Frage -- aber eben die zweite. */
|
||
if (ich.rolle !== 'creator' && meine) {
|
||
ziel.append(el('span', 'lage__meins', `${meine} davon bei dir`));
|
||
}
|
||
ziel.hidden = false;
|
||
}
|
||
|
||
function ausschnitteBauen(alle) {
|
||
const ziel = $('ausschnitte');
|
||
if (!ziel) return;
|
||
ziel.textContent = '';
|
||
|
||
const knopf = (wert, name, anzahl) => {
|
||
const b = el('button', 'schnitt');
|
||
b.type = 'button';
|
||
b.dataset.wert = wert;
|
||
b.setAttribute('aria-pressed', String(ausschnitt === wert));
|
||
b.append(el('span', null, name));
|
||
/* Die Zahl steht am Knopf. Ein Filter, der sich erst nach dem
|
||
Klick als leer herausstellt, kostet zweimal Aufmerksamkeit. */
|
||
b.append(el('span', 'schnitt__zahl', String(anzahl)));
|
||
if (!anzahl && wert) b.disabled = true;
|
||
b.addEventListener('click', () => {
|
||
ausschnitt = ausschnitt === wert ? '' : wert;
|
||
neuZeichnen();
|
||
});
|
||
return b;
|
||
};
|
||
|
||
ziel.append(knopf('', 'Alle', alle.length));
|
||
if (ich.rolle !== 'creator') {
|
||
ziel.append(knopf('meine', 'Nur meine',
|
||
alle.filter((a) => istOffen(a) && istMeine(a)).length));
|
||
}
|
||
ziel.append(knopf('heute', 'Heute fällig', alle.filter(istHeute).length));
|
||
const f = knopf('faellig', 'Überfällig', alle.filter(istFaellig).length);
|
||
f.dataset.warn = 'ja';
|
||
ziel.append(f);
|
||
ziel.hidden = false;
|
||
}
|
||
|
||
function creatorReiheBauen(alle) {
|
||
const ziel = $('creator-reihe');
|
||
if (!ziel) return;
|
||
ziel.textContent = '';
|
||
if (ich.rolle === 'creator') { ziel.hidden = true; return; }
|
||
|
||
/* Die Liste kommt aus den Aufgaben selbst, nicht aus einer
|
||
Personenabfrage: Dann stehen dort genau die Creator, die man
|
||
ohnehin sehen darf -- und niemals einer mehr. */
|
||
const nach = new Map();
|
||
for (const a of alle) {
|
||
if (!a.creator_id || !a.creator_name) continue;
|
||
const w = nach.get(a.creator_id) || { name: a.creator_name, offen: 0, faellig: 0 };
|
||
if (istOffen(a)) w.offen++;
|
||
if (istFaellig(a)) w.faellig++;
|
||
nach.set(a.creator_id, w);
|
||
}
|
||
/* Unter zwei Creatorn ist die Reihe eine Zeile ohne Auswahl. */
|
||
if (nach.size < 2) { ziel.hidden = true; return; }
|
||
|
||
/* "Deine Creator" stimmt fuer Scout und Manager -- bei DogFather
|
||
waeren es ALLE, und "deine" waere dort eine falsche Auskunft. */
|
||
ziel.append(el('span', 'creator-reihe__schild',
|
||
ich.rolle === 'admin' ? 'Alle Creator' : 'Deine Creator'));
|
||
const knopf = (id, name, offen, faellig) => {
|
||
const b = el('button', 'creator-knopf');
|
||
b.type = 'button';
|
||
b.setAttribute('aria-pressed', String(nurCreator === id));
|
||
b.append(el('span', 'creator-knopf__name', name));
|
||
b.append(el('span', 'creator-knopf__zahl', String(offen)));
|
||
if (faellig) {
|
||
const m = el('span', 'creator-knopf__warn', String(faellig));
|
||
m.title = `${faellig} überfällig`;
|
||
b.append(m);
|
||
}
|
||
b.addEventListener('click', () => {
|
||
nurCreator = nurCreator === id ? 0 : id;
|
||
neuZeichnen();
|
||
});
|
||
return b;
|
||
};
|
||
ziel.append(knopf(0, 'Alle', alle.filter(istOffen).length, alle.filter(istFaellig).length));
|
||
for (const [id, w] of [...nach.entries()].sort((a, b) => b[1].offen - a[1].offen)) {
|
||
ziel.append(knopf(id, w.name, w.offen, w.faellig));
|
||
}
|
||
ziel.hidden = false;
|
||
}
|
||
|
||
function neuZeichnen() {
|
||
ausschnitteBauen(alleAufgaben);
|
||
creatorReiheBauen(alleAufgaben);
|
||
zeichne(alleAufgaben.filter(passt));
|
||
/* Der Bereich folgt dem Creator-Filter mit -- wer sich eine Person
|
||
herausgreift, will auch nur deren Abgebrochenes sehen. Die
|
||
Ausschnitte (heute/überfällig) gelten dagegen nicht: Sie fragen
|
||
nach Fristen, und eine abgebrochene Aufgabe hat keine mehr. */
|
||
abgebrocheneZeigen(nurCreator
|
||
? alleAufgaben.filter((a) => a.creator_id === nurCreator)
|
||
: alleAufgaben);
|
||
}
|
||
|
||
function zeichne(aufgaben) {
|
||
const brett = $('brett');
|
||
brett.textContent = '';
|
||
brett.setAttribute('aria-busy', 'false');
|
||
|
||
for (const s of SPALTEN) {
|
||
const eigene = aufgaben.filter((a) => a.status === s.status);
|
||
const spalte = el('section', 'spalte');
|
||
spalte.dataset.status = s.status;
|
||
/* Die Karte merkt sich, wozu sie gehoert -- daraus findet der
|
||
Sprung vom Bericht die richtigen wieder, ohne die Liste ein
|
||
zweites Mal durchzugehen. */
|
||
|
||
const kopf = el('h2', 'spalte__kopf');
|
||
kopf.append(el('span', 'spalte__punkt'), el('span', null, s.name),
|
||
el('span', 'spalte__zahl', String(eigene.length)));
|
||
spalte.append(kopf);
|
||
/* Was diese Spalte bedeutet -- steht immer da, nicht nur wenn sie
|
||
leer ist. "Review" sagte einem Neuen nichts -- deshalb heisst
|
||
die Spalte seit dem 20.09.2026 "Zur Freigabe". */
|
||
spalte.append(el('p', 'spalte__unter', s.unter));
|
||
|
||
if (!eigene.length) spalte.append(el('p', 'spalte__leer', s.leer));
|
||
else for (const a of eigene) {
|
||
const k = karte(a);
|
||
k.dataset.status = a.status;
|
||
if (a.status !== 'erledigt' && a.frist && a.frist < heute) k.dataset.faellig = 'ja';
|
||
/* "Heute faellig" ist ein eigener Zustand, kein Sonderfall von
|
||
"ueberfaellig": Die Frist ist HEUTE und damit noch nicht
|
||
vorbei. Ohne dieses Merkmal koennte der Sprung von der
|
||
Startseite ("Heute faellig") nichts finden -- er zeigt dann
|
||
eine leere Auswahl, und man glaubt, man sei falsch
|
||
abgebogen. */
|
||
if (a.status !== 'erledigt' && a.frist === heute) k.dataset.heute = 'ja';
|
||
spalte.append(k);
|
||
}
|
||
|
||
brett.append(spalte);
|
||
}
|
||
}
|
||
|
||
/* ---------- Sprung vom Bericht -------------------------------------------
|
||
|
||
"wenn man auf die Kisten drueckt, sofort zu diesem Punkt
|
||
weitergeschickt werden" (01.09.2026).
|
||
|
||
Hier wird NICHT gefiltert, sondern hervorgehoben. Das Brett lebt
|
||
davon, dass man alle vier Spalten nebeneinander sieht -- wer nur die
|
||
ueberfaelligen zeigt, nimmt genau den Ueberblick weg, wegen dem man
|
||
hergekommen ist. */
|
||
/* ZWEI SCHLUESSEL KAMEN AM 09.09.2026 DAZU (screen1).
|
||
|
||
Filipe, mit Bild der sieben Zahlenkarten auf der Startseite: "wenn
|
||
ich auf die druecke soll ich auf zu denen punkten gebracht werden."
|
||
|
||
Fuenf der sieben konnten das schon (offen, arbeit, review,
|
||
erledigt, ueberfaellig) -- nur wusste es niemand, weil die Karten
|
||
keine Links waren. "Heute faellig" und "Abgebrochen" fehlten
|
||
ganz. */
|
||
const ZEIG_TEXT = {
|
||
offen: 'Aufgaben, die offen sind',
|
||
arbeit: 'Aufgaben, die gerade laufen',
|
||
review: 'Aufgaben, die auf Freigabe warten',
|
||
erledigt: 'Aufgaben, die erledigt sind',
|
||
ueberfaellig: 'Aufgaben, deren Frist vorbei ist',
|
||
heute: 'Aufgaben, die heute fällig sind',
|
||
abgebrochen: 'Aufgaben, die abgebrochen wurden',
|
||
};
|
||
|
||
function sprungZeigen() {
|
||
/* EINE EINZELNE AUFGABE, ueber `#a<nummer>` in der Adresse.
|
||
|
||
Sie steht VOR der Auswertung von `?zeigen=`, weil sie genauer
|
||
ist: Wer eine bestimmte Aufgabe meint, meint nicht ihre Spalte.
|
||
|
||
Die Nummer kommt aus dem Anker und nicht aus `?zeigen=`, weil
|
||
das Brett von drei Stellen aus so verlinkt wird -- und die
|
||
Schreibweise war schon da, nur gelesen hat sie niemand. */
|
||
const einzeln = /^#a(\d+)$/.exec(location.hash || "");
|
||
if (einzeln) {
|
||
const k = document.querySelector(`.karte[data-id="${einzeln[1]}"]`);
|
||
window.zielMarkieren?.(k ? [k] : [], 'Diese eine Aufgabe');
|
||
return;
|
||
}
|
||
|
||
const was = window.zielSchluessel?.();
|
||
if (!was || !ZEIG_TEXT[was]) return;
|
||
|
||
/* ABGEBROCHENE STEHEN NICHT IM BRETT, sondern in einem eigenen
|
||
Kasten weiter unten -- und der ist zugeklappt, solange niemand
|
||
ihn aufmacht. Ein Sprung dorthin muss ihn also erst OEFFNEN,
|
||
sonst landet man an einer Stelle, an der nichts zu sehen ist.
|
||
Genau die Sorte Sprung, die einen glauben laesst, der Knopf sei
|
||
kaputt. */
|
||
if (was === 'abgebrochen') {
|
||
/* `#abgebrochen` IST das <details>, nicht sein Behaelter. Im
|
||
ersten Anlauf stand hier `kasten.querySelector('details')` --
|
||
das haette null geliefert und den Kasten zugeklappt gelassen.
|
||
Nachgesehen im Markup statt vermutet. */
|
||
const kasten = $('abgebrochen');
|
||
if (kasten) kasten.open = true;
|
||
window.zielMarkieren?.(document.querySelectorAll('.abbruch-karte'), ZEIG_TEXT[was]);
|
||
return;
|
||
}
|
||
|
||
const treffer = was === 'ueberfaellig'
|
||
? document.querySelectorAll('.karte[data-faellig="ja"]')
|
||
: was === 'heute'
|
||
? document.querySelectorAll('.karte[data-heute="ja"]')
|
||
: document.querySelectorAll(`.karte[data-status="${was}"]`);
|
||
window.zielMarkieren?.(treffer, ZEIG_TEXT[was]);
|
||
}
|
||
|
||
/* =====================================================================
|
||
DIE KATEGORIE EINER AUFGABE (10.09.2026, Kapitel 6.1)
|
||
|
||
Filipes Entscheidung: nur bei den Modis. Fuer Creator, Scouts und
|
||
Manager bleibt das Formular unveraendert -- sie bekommen das Feld
|
||
nicht ausgeblendet, sondern gar nicht: Der Server schickt ihnen
|
||
keine Kategorien, und ohne Kategorien wird nichts gebaut.
|
||
|
||
WARUM DIE NAMEN NICHT HIER STEHEN: Diese Datei bekommt jeder
|
||
ausgeliefert, der die Seite oeffnet. Eine Liste, die im Browser
|
||
jedes Menschen liegt und nur bei einem einzigen benutzt wird, wirft
|
||
die Frage auf, fuer wen sie da ist. Genau die soll niemand stellen.
|
||
===================================================================== */
|
||
let kategorien = [];
|
||
let kanaele = [];
|
||
/* FUER WESSEN AUFGABEN die Kategorie gilt. Kommt fertig vom Server:
|
||
|
||
null -> fuer alle eigenen (das Feld steht immer da)
|
||
[Nummern] -> nur wenn eine dieser Personen gewaehlt ist
|
||
[] -> nie
|
||
|
||
ALS NUMMERN UND NICHT ALS ROLLENNAME, und das ist kein Zufall: Beim
|
||
ersten Anlauf stand hier ein Vergleich auf einen Rollennamen -- in
|
||
einer Datei, die JEDER bekommt, der die Seite oeffnet. Aus Nummern
|
||
laesst sich nichts schliessen; wer keine bekommt, sieht eine leere
|
||
Liste, und eine leere Liste sagt nichts. */
|
||
let kategorieFuer = [];
|
||
|
||
function kategorienEinsetzen() {
|
||
if (!kategorien.length) return;
|
||
for (const ziel of ['f-kategorie', 'b-kategorie']) {
|
||
const feld = $(ziel);
|
||
if (!feld || feld.dataset.fertig) continue;
|
||
feld.dataset.fertig = 'ja';
|
||
for (const k of kategorien) {
|
||
const o = el('option', null, k.name);
|
||
o.value = k.wert;
|
||
feld.append(o);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* Braucht diese Aufgabe eine Kategorie?
|
||
|
||
`null` heisst "immer" -- dann arbeitet die angemeldete Person
|
||
ausschliesslich an eigenen Aufgaben, und dort gilt sie unabhaengig
|
||
davon, wen sie eintraegt. Sonst entscheidet die gewaehlte Person.
|
||
Wer eine leere Liste bekommt, sieht das Feld nie. */
|
||
/* ---- DIE KANAELE (17.09.2026) --------------------------------------
|
||
Filipe: "ich hab ja auch 2 neben account, hasidog und dogfather
|
||
clips." Ohne diesen Wert ist "schneide drei Ausschnitte" bei drei
|
||
Accounts keine Aufgabe, sondern eine Frage.
|
||
|
||
EINFACHER ALS BEI DEN KATEGORIEN, und das ist Absicht: Die
|
||
Kategorie haengt daran, FUER WEN die Aufgabe ist (`kategorie_fuer`);
|
||
ein Kanal gilt fuer jede Aufgabe, die jemand aus dem Haus
|
||
anfasst. Zwei verschiedene Sichtbarkeitsregeln fuer zwei Felder,
|
||
die nebeneinander stehen, waeren eine Gelegenheit, die falsche zu
|
||
ziehen. Hier gibt es genau eine Frage: Darf diese Person
|
||
ueberhaupt Kanaele benutzen? */
|
||
function kanaeleEinsetzen() {
|
||
if (!kanaele.length) return;
|
||
for (const ziel of ['f-kanal', 'b-kanal']) {
|
||
const feld = $(ziel);
|
||
if (!feld) continue;
|
||
const vorher = feld.value;
|
||
/* "Für alle" ist kein Platzhalter, sondern eine Antwort -- vieles
|
||
ist nicht kanalgebunden (eine Absprache, ein Zugang, eine
|
||
Auswertung). Deshalb steht dort ein Wort und kein Strich. */
|
||
feld.textContent = '';
|
||
feld.append(el('option', null, 'Für alle'));
|
||
feld.firstChild.value = '';
|
||
for (const k of kanaele) {
|
||
const o = el('option', null, k.name);
|
||
o.value = k.wert;
|
||
if (k.text) o.title = k.text;
|
||
feld.append(o);
|
||
}
|
||
feld.value = vorher;
|
||
}
|
||
}
|
||
|
||
function kanalFeldZeigen(praefix) {
|
||
const huelle = $(praefix === 'f' ? 'feld-kanal' : 'b-feld-kanal');
|
||
if (!huelle) return;
|
||
huelle.hidden = !kanaele.length;
|
||
if (!kanaele.length && $(`${praefix}-kanal`)) $(`${praefix}-kanal`).value = '';
|
||
}
|
||
|
||
function kategorieNoetig(creatorId, verantId) {
|
||
if (kategorieFuer === null) return true;
|
||
if (!Array.isArray(kategorieFuer) || !kategorieFuer.length) return false;
|
||
return kategorieFuer.includes(Number(creatorId))
|
||
|| kategorieFuer.includes(Number(verantId));
|
||
}
|
||
|
||
function kategorieFeldZeigen(praefix) {
|
||
const huelle = $(praefix === 'f' ? 'feld-kategorie' : 'b-feld-kategorie');
|
||
if (!huelle) return;
|
||
const zeigen = kategorien.length
|
||
&& kategorieNoetig($(`${praefix}-creator`)?.value, $(`${praefix}-verant`)?.value);
|
||
huelle.hidden = !zeigen;
|
||
/* Verborgen heisst auch: nichts mitschicken. Ein Wert, der in einem
|
||
unsichtbaren Feld stehenbleibt, wandert sonst beim naechsten
|
||
Speichern mit -- und niemand sieht, woher er kommt. */
|
||
if (!zeigen && $(`${praefix}-kategorie`)) $(`${praefix}-kategorie`).value = '';
|
||
}
|
||
|
||
/* Die Aufwandsstufen kommen mit der Aufgabenliste. Hier
|
||
weitergereicht, damit die Auswahl im Dialog dieselbe Liste zeigt,
|
||
die der Server annimmt. */
|
||
function aufwandUebernehmen(daten) {
|
||
window.aufwandStufenSetzen?.(daten?.aufwand);
|
||
}
|
||
|
||
/* 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; }
|
||
const a = alleAufgaben.find((x) => x.id === Number(k.dataset.id));
|
||
const dabei = !!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);
|
||
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() {
|
||
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,
|
||
};
|
||
/* 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 || '';
|
||
$('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) : '';
|
||
$('b-loeschen').hidden = false;
|
||
}
|
||
kategorieFeldZeigen('b');
|
||
if ($('b-kategorie')) $('b-kategorie').value = a.kategorie || '';
|
||
if ($('b-kanal')) $('b-kanal').value = a.kanal || '';
|
||
if ($('b-aufwand')) $('b-aufwand').value = a.aufwand || '';
|
||
window.anhaengeLaden?.(a.id);
|
||
dialog.showModal();
|
||
$('b-titel').focus();
|
||
}
|
||
|
||
$('b-abbrechen').addEventListener('click', () => dialog.close());
|
||
|
||
/* Der Abbruch-Dialog. Am submit des Formulars und nicht am Knopf:
|
||
So löst auch Enter im Textfeld aus, und Esc schließt von selbst
|
||
(beides bringt <dialog> mit, wenn man es lässt). */
|
||
$('abbruch-form').addEventListener('submit', abbruchSenden);
|
||
$('abbruch-zurueck').addEventListener('click', () => $('abbruch-dialog').close());
|
||
|
||
$('b-speichern').addEventListener('click', async () => {
|
||
const daten = {
|
||
aufwand: $('b-aufwand') ? $('b-aufwand').value : undefined,
|
||
titel: $('b-titel').value,
|
||
beschreibung: $('b-text').value,
|
||
prioritaet: $('b-prio').value,
|
||
frist: $('b-frist').value,
|
||
};
|
||
/* 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();
|
||
await vorlagenbrettZeigen();
|
||
})();
|
||
|
||
/* =====================================================================
|
||
AUFGABEN AUS DER VORLAGE
|
||
|
||
Vier Bereiche, vier Stufen, drei Aufgaben je Feld. Der Block ist
|
||
bewusst KEINE zweite Aufgabenverwaltung: Er legt eine ganz normale
|
||
Aufgabe an, mit Frist und Verantwortlichem, und verschwindet danach
|
||
aus dem Weg. Alles Weitere macht das Brett darunter.
|
||
|
||
WARUM DIE BEIDEN REIHEN DIESELBEN KNOEPFE SIND WIE IN DER
|
||
CHECKLISTE: Es sind dieselben vier Bereiche und dieselben vier
|
||
Stufen. Zwei verschiedene Darstellungen fuer dieselbe Einteilung
|
||
waeren zwei Sachen zum Lernen statt einer.
|
||
|
||
OHNE CREATOR KEINE AUFGABE. Ein Creator legt sie fuer sich an, ein
|
||
Betreuer fuer den, den er oben gewaehlt hat. Ist keiner gewaehlt,
|
||
steht das da -- statt eines Knopfes, der beim Druecken einen Fehler
|
||
zeigt.
|
||
===================================================================== */
|
||
const BEREICHE_VORLAGE = [
|
||
{ schluessel: 'live', name: 'LIVE-Analyse' },
|
||
{ schluessel: 'content', name: 'Content-Ideen' },
|
||
{ schluessel: 'technik', name: 'Technik' },
|
||
{ schluessel: 'community', name: 'Community' },
|
||
];
|
||
const STUFEN_VORLAGE = [
|
||
{ schluessel: 'anfaenger', name: 'Anfänger',
|
||
hilfe: 'Was von Anfang an sitzen muss. Meist an einem Abend zu schaffen.' },
|
||
{ schluessel: 'fort', name: 'Fortgeschritten',
|
||
hilfe: 'Aus Zufall wird Routine. Braucht Wiederholung, also Wochen.' },
|
||
{ schluessel: 'profi', name: 'Profi',
|
||
hilfe: 'Gemessen statt geschätzt: ein Wert, ein Versuch, ein Beleg.' },
|
||
{ schluessel: 'meister', name: 'Meister',
|
||
hilfe: 'Was auch ohne dich weiterläuft — Vorlage, Vertretung, Ablage.' },
|
||
];
|
||
let vorlagen = null;
|
||
/* Der zweite Katalog -- welcher es ist, sagt der Server. Hier steht
|
||
bewusst nur "katalog": Im Browser darf nichts stehen, woraus sich
|
||
schliessen laesst, fuer wen er gedacht ist. */
|
||
let katalog = null;
|
||
let kGruppe = null;
|
||
/* WELCHE STUFE. `null` heisst alle -- und das ist die Vorgabe:
|
||
Wer eine Kategorie aufmacht, will erst sehen, was es gibt. */
|
||
let kStufe = null;
|
||
/* An WEN die Aufgabe geht. `null` heisst: das Feld oben im Formular
|
||
entscheidet, so wie bisher. */
|
||
let kZiel = null;
|
||
let personenListe = [];
|
||
/* Getrennt vom Inhalt, weil "nichts bekommen" ein gueltiges Ergebnis
|
||
ist. Ohne dieses Merkmal fragte die Seite bei jedem Neuzeichnen
|
||
erneut nach -- und bekaeme jedes Mal wieder nichts. */
|
||
let vorlagenGeladen = false;
|
||
let vBereich = 'live';
|
||
let vStufe = 'anfaenger';
|
||
|
||
/* ---------- DAS VORLAGENBRETT IST ZUGEKLAPPT (09.09.2026) ------------
|
||
Filipe: "ich will nur dass du dich informierst und schaust was man
|
||
vielleicht noch besser machen koennte von der benutzung ... ich red
|
||
von den aufgaben. das system von den aufgaben, die bedienung."
|
||
|
||
Gemessen auf dem Handy: Der Vorlagenblock fuellte anderthalb
|
||
Bildschirme, BEVOR die erste eigene Aufgabe kam. Nach dem Ausbau
|
||
von Profi und Meister am selben Tag ist er noch laenger geworden.
|
||
|
||
Das ist die falsche Reihenfolge: Vorlagen holt man selten, das
|
||
Brett benutzt man jeden Tag. Der Block bleibt an seiner Stelle --
|
||
dort gehoert er hin, weil daraus Aufgaben auf das Brett darunter
|
||
wandern --, aber er ist jetzt zu. Wer ihn aufmacht, findet ihn
|
||
beim naechsten Mal wieder offen: Wer Vorlagen holt, holt meistens
|
||
mehrere. */
|
||
const VORLAGEN_OFFEN = 'dfw_vorlagenbrett_offen';
|
||
let vOffen = false;
|
||
try { vOffen = localStorage.getItem(VORLAGEN_OFFEN) === 'ja'; } catch { vOffen = false; }
|
||
|
||
async function vorlagenbrettZeigen() {
|
||
const ziel = $('vorlagenbrett');
|
||
if (!ziel || !ich) return;
|
||
/* WER DIESEN KATALOG BEKOMMT, ENTSCHEIDET DER SERVER.
|
||
|
||
Hier stand kurzzeitig ein Vergleich auf einen Rollennamen -- in
|
||
einer Datei, die JEDER bekommt, der die Seite oeffnet. Jetzt
|
||
liefert /workspace/api/vorlagen fuer die Betroffenen schlicht
|
||
nichts, und `if (!vorlagen) return` weiter unten laesst das Brett
|
||
verborgen. Dieselbe Wirkung, ohne eine Zeile, die verraet, fuer
|
||
wen sie gilt. */
|
||
if (!vorlagenGeladen) {
|
||
try {
|
||
const a = await hole('/workspace/api/vorlagen');
|
||
if (!a.ok) return;
|
||
const d = await a.json();
|
||
vorlagen = d.aufgaben || null;
|
||
/* Ein Katalog mit null Eintraegen ist keiner. Ohne diese Pruefung
|
||
haette ein leeres `{gruppen:[],aufgaben:[]}` einen Block mit
|
||
Ueberschrift und nichts darin ergeben -- das sieht aus wie ein
|
||
Fehler, und es waere auch einer. */
|
||
katalog = d.katalog && Array.isArray(d.katalog.aufgaben) && d.katalog.aufgaben.length
|
||
? d.katalog : null;
|
||
vorlagenGeladen = true;
|
||
} catch { return; }
|
||
}
|
||
/* Zwei Kataloge, einer je Person -- nie beide. Wer den einen
|
||
bekommt, bekommt den anderen nicht; das entscheidet der Server. */
|
||
if (katalog) { katalogBrett(ziel); return; }
|
||
if (!vorlagen) return;
|
||
ziel.hidden = false;
|
||
ziel.textContent = '';
|
||
|
||
/* DER KOPF IST DER KNOPF. Ein eigener kleiner Schalter daneben waere
|
||
ein zweites Ziel fuer dieselbe Absicht -- und ein kleineres. */
|
||
const kopf = el('button', 'vorlagenbrett__kopf');
|
||
kopf.type = 'button';
|
||
kopf.setAttribute('aria-expanded', String(vOffen));
|
||
kopf.setAttribute('aria-controls', 'vorlagenbrett-koerper');
|
||
const kText = el('span', 'vorlagenbrett__kopftext');
|
||
kText.append(el('span', 'feldschild', 'Aufgaben aus der Vorlage'));
|
||
/* Zugeklappt zaehlt nicht die Erklaerung, sondern WIE VIEL darin
|
||
liegt -- sonst sieht der Block aus wie eine Ueberschrift ohne
|
||
Inhalt und niemand macht ihn je auf. */
|
||
const gesamt = Object.values(vorlagen)
|
||
.reduce((n, b) => n + Object.values(b).reduce((m, l) => m + l.length, 0), 0);
|
||
kText.append(el('span', 'vorlagenbrett__unter', vOffen
|
||
? 'Vier Bereiche, vier Stufen. Übernommen wird als ganz normale Aufgabe '
|
||
+ '– mit Frist, veränderbar, auf dem Brett darunter.'
|
||
: `${gesamt} fertige Aufgaben in vier Bereichen und vier Stufen – zum Übernehmen.`));
|
||
kopf.append(kText);
|
||
const pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
pfeil.setAttribute('viewBox', '0 0 24 24');
|
||
pfeil.setAttribute('class', 'klapp-pfeil');
|
||
pfeil.setAttribute('aria-hidden', 'true');
|
||
const pfad = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
pfad.setAttribute('d', 'm6 9.5 6 6 6-6');
|
||
pfeil.append(pfad);
|
||
kopf.append(pfeil);
|
||
kopf.addEventListener('click', () => {
|
||
vOffen = !vOffen;
|
||
try { localStorage.setItem(VORLAGEN_OFFEN, vOffen ? 'ja' : 'nein'); } catch { /* egal */ }
|
||
vorlagenbrettZeigen();
|
||
});
|
||
ziel.append(kopf);
|
||
ziel.dataset.auf = vOffen ? 'ja' : 'nein';
|
||
|
||
/* Zugeklappt wird der Rest gar nicht erst gebaut. Ihn nur zu
|
||
verstecken hiesse, bei jedem Neuzeichnen achtzig Karten
|
||
anzulegen, die niemand sieht. */
|
||
if (!vOffen) return;
|
||
|
||
const koerper = el('div', 'vorlagenbrett__koerper');
|
||
koerper.id = 'vorlagenbrett-koerper';
|
||
ziel.append(koerper);
|
||
|
||
/* Reihe 1: der Bereich. */
|
||
const r1 = el('div', 'stufenleiter');
|
||
r1.setAttribute('role', 'group');
|
||
r1.setAttribute('aria-label', 'Bereich wählen');
|
||
for (const b of BEREICHE_VORLAGE) {
|
||
const k = el('button', 'stufenleiter__knopf');
|
||
k.type = 'button';
|
||
k.dataset.stufe = b.schluessel;
|
||
k.setAttribute('aria-pressed', String(vBereich === b.schluessel));
|
||
k.append(el('span', 'stufenleiter__name', b.name));
|
||
k.addEventListener('click', () => { vBereich = b.schluessel; vorlagenbrettZeigen(); });
|
||
r1.append(k);
|
||
}
|
||
koerper.append(r1);
|
||
|
||
/* Reihe 2: die Stufe, mit der Zahl der Aufgaben darin. */
|
||
const r2 = el('div', 'stufenleiter');
|
||
r2.setAttribute('role', 'group');
|
||
r2.setAttribute('aria-label', 'Könnensstufe wählen');
|
||
for (const st of STUFEN_VORLAGE) {
|
||
const n = ((vorlagen[vBereich] || {})[st.schluessel] || []).length;
|
||
if (!n) continue;
|
||
const k = el('button', 'stufenleiter__knopf');
|
||
k.type = 'button';
|
||
k.dataset.stufe = st.schluessel;
|
||
k.setAttribute('aria-pressed', String(vStufe === st.schluessel));
|
||
k.title = st.hilfe;
|
||
k.append(el('span', 'stufenleiter__name', st.name));
|
||
k.append(el('span', 'stufenleiter__zahl', String(n)));
|
||
k.addEventListener('click', () => { vStufe = st.schluessel; vorlagenbrettZeigen(); });
|
||
r2.append(k);
|
||
}
|
||
koerper.append(r2);
|
||
const hilfe = STUFEN_VORLAGE.find((x) => x.schluessel === vStufe)?.hilfe;
|
||
if (hilfe) koerper.append(el('p', 'stufenleiter__hilfe', hilfe));
|
||
|
||
/* Für wen? */
|
||
const fuer = ich.rolle === 'creator' ? ich.id : nurCreator;
|
||
if (!fuer) {
|
||
koerper.append(el('p', 'vorlagenbrett__hinweis',
|
||
'Wähle oben einen Creator, dann lassen sich diese Aufgaben übernehmen.'));
|
||
}
|
||
|
||
/* WAS SCHON GEHOLT WURDE, STEHT DA (09.09.2026).
|
||
|
||
Vorher konnte man dieselbe Vorlage zweimal uebernehmen und
|
||
bekam die Aufgabe zweimal auf das Brett -- ohne jeden Hinweis.
|
||
Bei achtzig Vorlagen ueber vier Stufen weiss niemand auswendig,
|
||
was er letzten Monat schon geholt hat.
|
||
|
||
Verglichen wird ueber die KENNUNG der Vorlage, nicht ueber den
|
||
Titel: Ein Titel darf geaendert werden, und dann waere der
|
||
Vergleich still falsch. Die Kennung liegt seit heute an der
|
||
Aufgabe (Spalte `vorlage`).
|
||
|
||
Es braucht dafuer keine zusaetzliche Abfrage -- die Aufgaben
|
||
liegen ohnehin schon im Browser. */
|
||
const schonDa = new Map();
|
||
for (const a of alleAufgaben) {
|
||
if (!a.vorlage) continue;
|
||
if (fuer && a.creator_id !== fuer) continue;
|
||
schonDa.set(a.vorlage, a);
|
||
}
|
||
|
||
const liste = (vorlagen[vBereich] || {})[vStufe] || [];
|
||
const box = el('div', 'vorlagenbrett__liste');
|
||
for (const [nr, v] of liste.entries()) {
|
||
const karte = el('div', 'vorlagenbrett__karte');
|
||
const schon = schonDa.get(v.schluessel);
|
||
if (schon) karte.dataset.schon = 'ja';
|
||
karte.append(el('h3', 'vorlagenbrett__titel', v.titel));
|
||
karte.append(el('p', 'vorlagenbrett__text', v.text));
|
||
const fuss = el('div', 'vorlagenbrett__fuss');
|
||
fuss.append(el('span', 'vorlagenbrett__frist',
|
||
v.tage === 1 ? 'Frist: morgen' : `Frist: in ${v.tage} Tagen`));
|
||
if (schon) {
|
||
/* Der Zustand steht dabei, nicht nur die Tatsache. "Schon
|
||
uebernommen" allein liesse offen, ob sie noch offen ist oder
|
||
laengst abgehakt -- und danach entscheidet man, ob man sie
|
||
ein zweites Mal holt. */
|
||
fuss.append(el('span', 'vorlagenbrett__schon',
|
||
`schon übernommen · ${STATUS_NAME[schon.status] || schon.status}`));
|
||
}
|
||
if (fuer) {
|
||
const k = el('button', 'knopf knopf--klein', schon ? 'Nochmal' : 'Übernehmen');
|
||
k.type = 'button';
|
||
if (schon) k.title = 'Diese Vorlage liegt schon auf dem Brett – noch einmal übernehmen?';
|
||
k.addEventListener('click', () => uebernehmen(k, { art: 'aufgabe', nr }, fuer));
|
||
fuss.append(k);
|
||
}
|
||
karte.append(fuss);
|
||
box.append(karte);
|
||
}
|
||
/* Und wie viele der Stufe noch offen sind -- die Zahl, nach der man
|
||
eigentlich sucht, wenn man den Block aufmacht. */
|
||
if (liste.length) {
|
||
const offen = liste.filter((v) => !schonDa.has(v.schluessel)).length;
|
||
koerper.append(el('p', 'vorlagenbrett__stand', offen
|
||
? `${offen} von ${liste.length} noch nicht übernommen.`
|
||
: `Alle ${liste.length} dieser Stufe sind übernommen.`));
|
||
}
|
||
koerper.append(box);
|
||
|
||
if (fuer && liste.length > 1) {
|
||
const alle = el('button', 'abmelden', `Alle ${liste.length} übernehmen`);
|
||
alle.title = 'Legt alle Aufgaben dieser Stufe an – auch die, die schon auf dem Brett liegen.';
|
||
alle.type = 'button';
|
||
alle.style.marginTop = '12px';
|
||
alle.addEventListener('click', () => uebernehmen(alle, { art: 'aufgaben_alle' }, fuer));
|
||
koerper.append(alle);
|
||
}
|
||
}
|
||
|
||
|
||
/* =====================================================================
|
||
DAS ZWEITE VORLAGENBRETT (10.09.2026)
|
||
|
||
Derselbe Block, derselbe Kopf, dieselben Karten -- nur eine andere
|
||
Einteilung: neun Etappen statt vier Bereichen mal vier Stufen. Die
|
||
Bausteine sind bewusst dieselben (vorlagenbrett__karte,
|
||
stufenleiter, ...): Ein zweites Aussehen fuer dieselbe Sache waere
|
||
zwei Dinge zum Lernen statt einem -- und eine zweite Stelle, an der
|
||
jemand eine neue CSS-Klasse in sieben Kopien pflegen muesste.
|
||
|
||
WAS DRINSTEHT, KOMMT VOM SERVER, samt Gruppennamen und Texten. In
|
||
dieser Datei steht kein Wort darueber, fuer wen dieser Katalog ist.
|
||
===================================================================== */
|
||
function katalogBrett(ziel) {
|
||
ziel.hidden = false;
|
||
ziel.textContent = '';
|
||
if (!kGruppe) kGruppe = (katalog.gruppen[0] || {}).schluessel || null;
|
||
|
||
const kopf = el('button', 'vorlagenbrett__kopf');
|
||
kopf.type = 'button';
|
||
kopf.setAttribute('aria-expanded', String(vOffen));
|
||
kopf.setAttribute('aria-controls', 'vorlagenbrett-koerper');
|
||
const kText = el('span', 'vorlagenbrett__kopftext');
|
||
kText.append(el('span', 'feldschild', 'Aufgaben aus der Vorlage'));
|
||
/* DER SATZ MUSS SAGEN, WONACH SORTIERT IST. Bis zum 16.09.2026 stand
|
||
hier "Nach Etappen sortiert" -- und seit dem Umbau auf Kategorie
|
||
mal Stufe war das schlicht falsch. Ein Satz, der etwas anderes
|
||
beschreibt als das, was darunter steht, kostet mehr Zeit als er
|
||
spart. */
|
||
kText.append(el('span', 'vorlagenbrett__unter', vOffen
|
||
? 'Nach Kategorie und Stufe sortiert. Übernommen wird als ganz normale '
|
||
+ 'Aufgabe – mit Frist und Kategorie, veränderbar, auf dem Brett darunter.'
|
||
: `${katalog.aufgaben.length} fertige Aufgaben in `
|
||
+ `${katalog.gruppen.length} Kategorien – zum Übernehmen.`));
|
||
kopf.append(kText);
|
||
const pfeil = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
pfeil.setAttribute('viewBox', '0 0 24 24');
|
||
pfeil.setAttribute('class', 'klapp-pfeil');
|
||
pfeil.setAttribute('aria-hidden', 'true');
|
||
const pfad = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||
pfad.setAttribute('d', 'm6 9.5 6 6 6-6');
|
||
pfeil.append(pfad);
|
||
kopf.append(pfeil);
|
||
kopf.addEventListener('click', () => {
|
||
vOffen = !vOffen;
|
||
try { localStorage.setItem(VORLAGEN_OFFEN, vOffen ? 'ja' : 'nein'); } catch { /* egal */ }
|
||
vorlagenbrettZeigen();
|
||
});
|
||
ziel.append(kopf);
|
||
ziel.dataset.auf = vOffen ? 'ja' : 'nein';
|
||
/* Zugeklappt wird der Rest gar nicht erst gebaut -- sonst lägen bei
|
||
jedem Neuzeichnen sechzig Karten da, die niemand sieht. */
|
||
if (!vOffen) return;
|
||
|
||
const koerper = el('div', 'vorlagenbrett__koerper');
|
||
koerper.id = 'vorlagenbrett-koerper';
|
||
ziel.append(koerper);
|
||
|
||
/* Die Etappen als Reihe -- mit der Zahl darin, damit man sieht, wo
|
||
etwas liegt, bevor man klickt. */
|
||
const reihe = el('div', 'stufenleiter');
|
||
reihe.setAttribute('role', 'group');
|
||
reihe.setAttribute('aria-label', 'Kategorie wählen');
|
||
for (const g of katalog.gruppen) {
|
||
const n = katalog.aufgaben.filter((v) => v.gruppe === g.schluessel).length;
|
||
if (!n) continue;
|
||
const k = el('button', 'stufenleiter__knopf');
|
||
k.type = 'button';
|
||
k.dataset.stufe = g.schluessel;
|
||
k.setAttribute('aria-pressed', String(kGruppe === g.schluessel));
|
||
k.append(el('span', 'stufenleiter__name', g.name));
|
||
k.append(el('span', 'stufenleiter__zahl', String(n)));
|
||
k.addEventListener('click', () => { kGruppe = g.schluessel; vorlagenbrettZeigen(); });
|
||
reihe.append(k);
|
||
}
|
||
koerper.append(reihe);
|
||
|
||
/* DERSELBE BAUSTEIN WIE BEIM ANDEREN BRETT (16.09.2026).
|
||
|
||
Filipe: "ich will dass es vom aussehen auch genau gleich
|
||
aussieht und so geil wie bei den anderen kategorien."
|
||
|
||
Hier stand `vorlagenbrett__stand` -- das ist die Zeile fuer
|
||
ZAHLEN ("3 von 11 noch nicht uebernommen"), kleiner und mit
|
||
Ziffern in Tabellenbreite. Die Erklaerung unter der Auswahl
|
||
heisst drueben `stufenleiter__hilfe` und sieht anders aus.
|
||
Zwei Bretter, die dasselbe tun und verschieden aussehen, wirken
|
||
wie zwei verschiedene Sachen. */
|
||
const gruppe = katalog.gruppen.find((g) => g.schluessel === kGruppe);
|
||
if (gruppe?.text) koerper.append(el('p', 'stufenleiter__hilfe', gruppe.text));
|
||
|
||
/* ---- Die Stufe (16.09.2026) ---------------------------------------
|
||
|
||
Filipe: "wie bei den aufgaben wo sie für mich haben auch mit
|
||
kategorien und vielen aufgaben."
|
||
|
||
Dieselbe Kategorie bedeutet für jemanden in der ersten Woche
|
||
etwas anderes als für jemanden, der einarbeitet. Eine Aufgabe
|
||
der Stufe "erfahren" an einen Neuen zu geben ist kein
|
||
Kompliment, sondern ein Überfallen.
|
||
|
||
DIE STUFEN KOMMEN VOM SERVER, nicht aus einer Liste hier: Eine
|
||
zweite Liste im Browser wäre die, die beim nächsten Zusatz
|
||
veraltet -- und ausgeliefert wird sie an jeden. */
|
||
const inGruppe = katalog.aufgaben.filter((v) => v.gruppe === kGruppe);
|
||
if (Array.isArray(katalog.stufen) && katalog.stufen.length) {
|
||
/* VOLLE GROESSE, nicht `--fein`: Drueben ist die zweite Reihe
|
||
(die Stufen) genauso gross wie die erste. */
|
||
const sr = el('div', 'stufenleiter');
|
||
sr.setAttribute('role', 'group');
|
||
sr.setAttribute('aria-label', 'Stufe wählen');
|
||
const alle = el('button', 'stufenleiter__knopf');
|
||
alle.type = 'button';
|
||
alle.setAttribute('aria-pressed', String(kStufe === null));
|
||
alle.append(el('span', 'stufenleiter__name', 'Alle'));
|
||
alle.append(el('span', 'stufenleiter__zahl', String(inGruppe.length)));
|
||
alle.addEventListener('click', () => { kStufe = null; vorlagenbrettZeigen(); });
|
||
sr.append(alle);
|
||
for (const st of katalog.stufen) {
|
||
const n = inGruppe.filter((v) => v.stufe === st.schluessel).length;
|
||
if (!n) continue;
|
||
const k = el('button', 'stufenleiter__knopf');
|
||
k.type = 'button';
|
||
k.dataset.stufe = st.schluessel;
|
||
k.setAttribute('aria-pressed', String(kStufe === st.schluessel));
|
||
k.title = st.text || '';
|
||
k.append(el('span', 'stufenleiter__name', st.name));
|
||
k.append(el('span', 'stufenleiter__zahl', String(n)));
|
||
k.addEventListener('click', () => { kStufe = st.schluessel; vorlagenbrettZeigen(); });
|
||
sr.append(k);
|
||
}
|
||
koerper.append(sr);
|
||
const gewaehlt = katalog.stufen.find((x) => x.schluessel === kStufe);
|
||
if (gewaehlt?.text) koerper.append(el('p', 'stufenleiter__hilfe', gewaehlt.text));
|
||
}
|
||
|
||
/* Was schon auf dem Brett liegt -- aus den Aufgaben, die ohnehin
|
||
geladen sind. Verglichen wird die Kennung der Vorlage und nicht
|
||
der Titel: Der darf geändert werden. */
|
||
const schonDa = new Map();
|
||
for (const a of alleAufgaben) if (a.vorlage) schonDa.set(a.vorlage, a);
|
||
|
||
/* ---- AN WEN? Mit der Last daneben (16.09.2026) -------------------
|
||
|
||
Bis heute kam die Person aus dem Formular GANZ OBEN auf der
|
||
Seite: Man wählt sie dort, scrollt herunter und drückt hier
|
||
„Übernehmen". Wer das nicht weiß, bekommt „Bitte zuerst eine
|
||
Person wählen" und sucht.
|
||
|
||
UND DAS WICHTIGERE: Man sah beim Vergeben nicht, wer schon wie
|
||
viel hat. Zu wenig Zeit ist in den Untersuchungen zu
|
||
Moderatoren der meistgenannte Grund fürs Aufhören — und die
|
||
Person, die Aufgaben verteilt, ist die einzige, die das
|
||
verhindern kann. Dafür muss die Zahl dort stehen, wo entschieden
|
||
wird, nicht auf einer Auswertung, die man hinterher aufruft.
|
||
|
||
KEINE SCHWELLE, KEINE WARNFARBE. Was „zu viel" ist, hängt vom
|
||
Menschen ab; eine feste Grenze wäre geraten. Was NICHT geraten
|
||
ist: dass etwas überfällig liegt. Nur das wird markiert. */
|
||
/* WER IN FRAGE KOMMT, SAGT DER SERVER (17.09.2026).
|
||
|
||
Hier standen zwei Rollennamen. Diese Datei ist ohne Anmeldung
|
||
aus dem offenen Netz lesbar -- damit war der verborgene Zugang
|
||
darin nachzulesen. Gefunden von der Wortleck-Pruefung, nicht vom
|
||
Auge. */
|
||
const zielbar = personenListe.filter((p) => p.fuer_katalog);
|
||
if (zielbar.length && $('feld-verant') && !$('feld-verant').hidden) {
|
||
/* DIE DRITTE REIHE IST ETWAS ANDERES als die zwei darueber: Sie
|
||
waehlt keinen Ausschnitt des Katalogs, sondern einen MENSCHEN.
|
||
Deshalb bleibt sie etwas kleiner -- gleich aussehende Reihen,
|
||
die Verschiedenes tun, sind schlechter als zwei, die man
|
||
unterscheiden kann. */
|
||
koerper.append(el('p', 'feldschild', 'An wen'));
|
||
const wer = el('div', 'stufenleiter stufenleiter--wer');
|
||
wer.setAttribute('role', 'group');
|
||
wer.setAttribute('aria-label', 'Person wählen');
|
||
for (const p of zielbar) {
|
||
const seine = alleAufgaben.filter((a) => a.verantwortlich_id === p.id);
|
||
const offenN = seine.filter(istOffen).length;
|
||
const spaet = seine.filter(istFaellig).length;
|
||
const k = el('button', 'stufenleiter__knopf');
|
||
k.type = 'button';
|
||
if (spaet) k.dataset.spaet = 'ja';
|
||
k.setAttribute('aria-pressed', String(String(kZiel) === String(p.id)));
|
||
k.title = spaet
|
||
? `${p.name}: ${offenN} offen, davon ${spaet} überfällig`
|
||
: `${p.name}: ${offenN} offen`;
|
||
k.append(el('span', 'stufenleiter__name', p.name));
|
||
k.append(el('span', 'stufenleiter__zahl', String(offenN)));
|
||
if (spaet) k.append(el('span', 'stufenleiter__spaet', `${spaet} spät`));
|
||
k.addEventListener('click', () => {
|
||
kZiel = String(kZiel) === String(p.id) ? null : String(p.id);
|
||
vorlagenbrettZeigen();
|
||
});
|
||
wer.append(k);
|
||
}
|
||
koerper.append(wer);
|
||
const gewaehlt = zielbar.find((p) => String(p.id) === String(kZiel));
|
||
koerper.append(el('p', 'stufenleiter__hilfe', gewaehlt
|
||
? `Übernommene Aufgaben gehen an ${gewaehlt.name}.`
|
||
: 'Niemand gewählt – es gilt die Person aus dem Formular oben.'));
|
||
}
|
||
|
||
const liste = katalog.aufgaben
|
||
.map((v, nr) => ({ ...v, nr }))
|
||
.filter((v) => v.gruppe === kGruppe)
|
||
.filter((v) => !kStufe || v.stufe === kStufe);
|
||
const box = el('div', 'vorlagenbrett__liste');
|
||
for (const v of liste) {
|
||
const karte = el('div', 'vorlagenbrett__karte');
|
||
const schon = schonDa.get(v.schluessel);
|
||
if (schon) karte.dataset.schon = 'ja';
|
||
karte.append(el('h3', 'vorlagenbrett__titel', v.titel));
|
||
karte.append(el('p', 'vorlagenbrett__text', v.text));
|
||
const fuss = el('div', 'vorlagenbrett__fuss');
|
||
fuss.append(el('span', 'vorlagenbrett__frist',
|
||
v.tage === 1 ? 'Frist: morgen' : `Frist: in ${v.tage} Tagen`));
|
||
/* Die empfohlene Rolle steht im Dokument neben jeder Aufgabe und
|
||
ist die halbe Auskunft: "Technik-Check" heisst etwas anderes,
|
||
je nachdem, wer ihn macht. */
|
||
if (v.rolle) fuss.append(el('span', 'vorlagenbrett__frist', `für: ${v.rolle}`));
|
||
if (schon) {
|
||
fuss.append(el('span', 'vorlagenbrett__schon',
|
||
`schon übernommen · ${STATUS_NAME[schon.status] || schon.status}`));
|
||
}
|
||
const k = el('button', 'knopf knopf--klein', schon ? 'Nochmal' : 'Übernehmen');
|
||
k.type = 'button';
|
||
if (schon) k.title = 'Diese Vorlage liegt schon auf dem Brett – noch einmal übernehmen?';
|
||
k.addEventListener('click', () => uebernehmen(k,
|
||
{ art: 'katalog', nr: v.nr, gruppe: kGruppe, person_id: katalogZiel() }));
|
||
fuss.append(k);
|
||
karte.append(fuss);
|
||
box.append(karte);
|
||
}
|
||
|
||
if (liste.length) {
|
||
const offen = liste.filter((v) => !schonDa.has(v.schluessel)).length;
|
||
koerper.append(el('p', 'vorlagenbrett__stand', offen
|
||
? `${offen} von ${liste.length} noch nicht übernommen.`
|
||
: `Alle ${liste.length} davon sind übernommen.`));
|
||
}
|
||
koerper.append(box);
|
||
|
||
if (liste.length > 1) {
|
||
const alle = el('button', 'abmelden', `Alle ${liste.length} übernehmen`);
|
||
alle.title = 'Legt alle Aufgaben an, die gerade in der Liste stehen – '
|
||
+ 'auch die, die schon auf dem Brett liegen.';
|
||
alle.type = 'button';
|
||
alle.style.marginTop = '12px';
|
||
/* DIE STUFE WANDERT MIT (16.09.2026). Ohne sie legte dieser Knopf
|
||
alle Aufgaben der Kategorie an -- auch die, die gerade
|
||
ausgeblendet sind. Der Knopf sagt "Alle 4 übernehmen" und
|
||
legte elf an; das merkt man erst auf dem Brett. */
|
||
alle.addEventListener('click', () => uebernehmen(alle,
|
||
{ art: 'katalog_alle', gruppe: kGruppe, stufe: kStufe || '',
|
||
person_id: katalogZiel() }));
|
||
koerper.append(alle);
|
||
}
|
||
}
|
||
|
||
/* FUER WEN wird uebernommen? Wer nur eigene Aufgaben hat, meint sich
|
||
selbst -- der Server setzt das dann ein und braucht hier nichts.
|
||
Wer fuer andere eintragen darf, muss die Person oben ausgewaehlt
|
||
haben; sonst sagt der Server es. */
|
||
function katalogZiel() {
|
||
/* Die Auswahl im Vorlagenbrett geht vor -- sie steht dort, wo man
|
||
gerade hinsieht. Das Formular oben bleibt als Rueckfall, damit
|
||
der bisherige Weg weiter funktioniert. */
|
||
return kZiel || $('f-verant')?.value || $('f-creator')?.value || null;
|
||
}
|
||
|
||
/* Ein Weg fuer beide Knoepfe -- einzeln und alle. Zwei fast gleiche
|
||
Funktionen waeren der sichere Weg dazu, dass die eine irgendwann
|
||
anders reagiert als die andere. */
|
||
async function uebernehmen(knopf, was, fuer) {
|
||
knopf.disabled = true;
|
||
const vorher = knopf.textContent;
|
||
knopf.textContent = 'Wird übernommen …';
|
||
try {
|
||
const a = await hole('/workspace/api/vorlagen/uebernehmen', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ...was, bereich: vBereich, stufe: vStufe, creator_id: fuer }),
|
||
});
|
||
if (!a.ok) {
|
||
const f = await a.json().catch(() => ({}));
|
||
melde(sagWas(f.fehler, 'Das hat nicht geklappt.'));
|
||
knopf.disabled = false; knopf.textContent = vorher;
|
||
return;
|
||
}
|
||
melde('');
|
||
/* Erst das Brett neu laden, dann den Block -- so sieht man die
|
||
neue Aufgabe sofort dort, wo sie ab jetzt lebt. */
|
||
await laden();
|
||
await vorlagenbrettZeigen();
|
||
} catch {
|
||
melde('Das hat nicht geklappt.');
|
||
knopf.disabled = false; knopf.textContent = vorher;
|
||
}
|
||
}
|
||
})();
|
||
|
||
/* =====================================================================
|
||
ANHAENGE AN EINER AUFGABE (15.09.2026, Stufe 9)
|
||
|
||
KEIN ZWEITER HOCHLADEWEG. Die Datei geht an denselben Weg wie in der
|
||
Dateiablage (/workspace/api/dateien) und bekommt nur einen Kopf mehr:
|
||
die Nummer der Aufgabe. Sichtbarkeit, Freigaben, Fassungen und
|
||
Protokoll bleiben damit genau dort, wo sie schon richtig sind.
|
||
|
||
WAS BEIM LOESCHEN DER AUFGABE PASSIERT, steht in der Datenbank
|
||
(ON DELETE SET NULL) und nicht hier: Die Datei bleibt, sie verliert
|
||
nur ihren Bezug. Wer eine Aufgabe loescht, will die Aufgabe loeschen.
|
||
===================================================================== */
|
||
(() => {
|
||
const $ = (id) => document.getElementById(id);
|
||
const el = (t, k, text) => {
|
||
const n = document.createElement(t);
|
||
if (k) n.className = k;
|
||
if (text !== undefined) n.textContent = text;
|
||
return n;
|
||
};
|
||
const feld = $('b-datei');
|
||
const liste = $('b-anhaenge');
|
||
const stand = $('b-anhang-stand');
|
||
if (!feld || !liste) return;
|
||
|
||
let fuerAufgabe = null;
|
||
|
||
const groesse = (n) => (n >= 1048576
|
||
? (n / 1048576).toFixed(1) + ' MB'
|
||
: Math.max(1, Math.round(n / 1024)) + ' KB');
|
||
|
||
const sagen = (t) => { if (stand) stand.textContent = t || ''; };
|
||
|
||
window.anhaengeLaden = async function (aufgabeId) {
|
||
fuerAufgabe = aufgabeId;
|
||
liste.textContent = '';
|
||
sagen('');
|
||
try {
|
||
const a = await fetch('/workspace/api/aufgaben/' + aufgabeId + '/dateien',
|
||
{ credentials: 'same-origin' });
|
||
if (!a.ok) return;
|
||
const d = await a.json();
|
||
if (!d.dateien?.length) {
|
||
liste.append(el('span', 'anhaenge__leer', 'Noch nichts angehängt.'));
|
||
return;
|
||
}
|
||
for (const x of d.dateien) {
|
||
const v = el('a', 'anhaenge__datei');
|
||
/* Der Herunterlade-Weg heisst /inhalt -- /dateien/:id allein
|
||
gibt es nicht. Beim ersten Anlauf stand hier der Verweis ins
|
||
Leere; gefunden hat es der Blick in die Routen, nicht der
|
||
Klick. */
|
||
v.href = '/workspace/api/dateien/' + x.id + '/inhalt';
|
||
v.append(el('span', 'anhaenge__name', x.name_original));
|
||
v.append(el('span', 'anhaenge__groesse', groesse(x.groesse)));
|
||
/* Eine ueberholte Fassung ALS SOLCHE kennzeichnen -- sonst laedt
|
||
jemand hier die alte herunter und merkt es nicht. Genau der
|
||
Fall, fuer den es die Fassungen gibt. */
|
||
if (x.ersetzt_durch) {
|
||
const w = el('span', 'anhaenge__alt', 'nicht mehr aktuell');
|
||
w.title = 'Es gibt eine neuere Fassung dieser Datei.';
|
||
v.append(w);
|
||
} else if (x.fassung > 1) {
|
||
v.append(el('span', 'anhaenge__fassung', 'Fassung ' + x.fassung));
|
||
}
|
||
liste.append(v);
|
||
}
|
||
} catch { /* der Dialog ist auch ohne Anhaenge benutzbar */ }
|
||
};
|
||
|
||
feld.addEventListener('change', async () => {
|
||
const f = feld.files?.[0];
|
||
if (!f || !fuerAufgabe) return;
|
||
/* 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);
|
||
}
|
||
})();
|