Filipe: "ich will dass du dass system jetzt wie die vier kategorien bedient werden, perfektionnierst ... ich red von den aufgaben. das system von den aufgaben, die bedienung ... die hauptkachel von der kategorie nicht veraendern oder anfassen, die bleiben so." Die vier Kacheln sind unberuehrt. Geaendert ist, was danach kommt. --- 1. DAS VORLAGENBRETT WAR IM WEG --- Gemessen am Handy: Der Vorlagenblock fuellte ANDERTHALB BILDSCHIRME, bevor die erste eigene Aufgabe kam. Und mein eigener Ausbau von Profi und Meister ein paar Stunden vorher hat ihn noch laenger gemacht -- aus 48 Vorlagen wurden 80. 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), ist aber zugeklappt. Zugeklappt steht dort, WIE VIEL darin liegt -- sonst sieht er aus wie eine Ueberschrift ohne Inhalt und niemand macht ihn auf. Wer ihn aufmacht, findet ihn beim naechsten Mal offen: Wer Vorlagen holt, holt meistens mehrere. Und zugeklappt werden die achtzig Karten GAR NICHT ERST GEBAUT, nicht nur versteckt. --- 2. MAN SAH NICHT, WAS MAN SCHON GEHOLT HATTE --- Dieselbe Vorlage liess sich zweimal uebernehmen, und die Aufgabe stand dann zweimal auf dem Brett -- ohne jeden Hinweis. Bei achtzig Vorlagen ueber vier Stufen weiss niemand auswendig, was er letzten Monat schon geholt hat. Die Kennung der Vorlage steht jetzt an der Aufgabe (neue Spalte `vorlage`). Ueber den TITEL zu vergleichen waere die naheliegende Abkuerzung gewesen -- und faellt in dem Moment um, in dem jemand den Titel einer uebernommenen Aufgabe aendert. Uebernommene Karten treten zurueck (nicht: verschwinden -- wer sie ausblendet, nimmt die Moeglichkeit, sie bewusst noch einmal zu holen), tragen "schon uebernommen" MIT ZUSTAND, und ihr Knopf heisst "Nochmal". Darunter steht der Stand: "5 von 7 noch nicht uebernommen." Es braucht dafuer keine zweite Abfrage -- die Aufgaben liegen ohnehin im Browser. --- 3. DRINGEND SCHLAEGT WICHTIG --- Die Liste war nach PRIORITAET sortiert, dann nach Frist. Auf dem Brett stand damit eine seit vier Tagen ueberfaellige Aufgabe mit Prioritaet "niedrig" UNTER einer, die als "hoch" eingetragen ist und erst in dreissig Tagen faellig wird. Wer die Spalte von oben liest, faengt dann mit dem Falschen an. Eine Prioritaet ist eine Einschaetzung von damals, eine ueberschrittene Frist eine Tatsache von heute. Jetzt: erst was faellig ist, dann nach Datum, und die Prioritaet entscheidet nur noch bei gleichem Datum. Die Sortierung steht im SERVER -- Startseite und Uebersicht lesen dieselbe Liste, und zwei Sortierungen fuer dieselbe Frage laufen auseinander. --- 4. "WICHTIG" STEHT JETZT DA --- Die Prioritaet war ein 3 px breiter Rand links -- direkt neben der farbigen Kante der Spalte und damit praktisch unsichtbar. Als Wort steht sie dort, wo man sie liest. NUR bei "hoch" und nur solange nicht erledigt: Eine Marke an jeder Karte waere keine Marke mehr. --- Pruefung --- pruef-aufgaben-vorlagen: zugeklappt als Vorgabe (gemessen wird, dass die Karten gar nicht gebaut werden), Aufklappen, uebernommene Vorlage markiert samt Zustand und "Nochmal"-Knopf, Stand darunter, Zustand ueberlebt das Neuladen -- mit Gegenprobe, dass die uebrigen NICHT markiert sind. Dabei fiel eine eigene Nachlaessigkeit auf: Die Pruefung "die Karten sind gestaltet" mass zu einem Zeitpunkt, an dem es zugeklappt gar keine Karte gab -- getComputedStyle auf null liefert nichts, und der Haken wurde rot, ohne dass etwas kaputt war. Sie misst jetzt nach dem Aufklappen. pruef-aufgabenbrett: die Sortierung an zwei absichtlich unguenstig angelegten Aufgaben (ueberfaellig+niedrig gegen fern+hoch), plus die Gegenprobe, dass bei GLEICHER Frist weiterhin die Prioritaet entscheidet -- sonst waere die Prioritaet wirkungslos geworden, und das waere die andere Uebertreibung. Ausserdem gruen: pruef-css-klassen, pruef-start-ansicht, pruef-uebersicht. Co-Authored-By: Claude Opus 5 <[email protected]>
1115 lines
45 KiB
JavaScript
1115 lines
45 KiB
JavaScript
/* ===================================================================
|
||
Aufgabenbrett.
|
||
|
||
Der Server entscheidet, wer was sehen und ändern darf -- diese Datei
|
||
zeigt nur an. Was hier ausgeblendet wird, ist Bequemlichkeit, kein
|
||
Schutz.
|
||
=================================================================== */
|
||
(() => {
|
||
'use strict';
|
||
|
||
/* DogFather und Manager haben dieselben Rechte -- die Unterscheidung
|
||
macht nur die Personenverwaltung, und die prueft der Server. */
|
||
/* Die Leitungsliste steht in bereiche.js -- eine Antwort statt neun.
|
||
Sie stand hier frueher selbst, ohne 'spicy', und genau das war der
|
||
Fehler, den Filipe am 07.09.2026 gefunden hat. */
|
||
const LEITUNG = window.Bereiche.LEITUNG;
|
||
|
||
/* `leer` ist der Satz, der in einer leeren Spalte steht. Vorher stand
|
||
dort in allen vier Spalten ein Gedankenstrich -- vier graue Striche
|
||
nebeneinander sehen aus, als sei die Seite kaputt, obwohl gar nichts
|
||
kaputt ist. Jeder Satz sagt jetzt, was das Leersein BEDEUTET: eine
|
||
leere Review-Spalte ist etwas anderes als eine leere Erledigt-Spalte.
|
||
`unter` erklärt die Spalte auch dann, wenn sie voll ist. */
|
||
const SPALTEN = [
|
||
{ status: 'offen', name: 'Offen', unter: 'Angenommen, noch nicht angefangen',
|
||
leer: 'Nichts liegt herum.' },
|
||
{ status: 'arbeit', name: 'In Arbeit', unter: 'Läuft gerade',
|
||
leer: 'Gerade läuft nichts.' },
|
||
{ status: 'review', name: 'Review', unter: 'Wartet auf Freigabe',
|
||
leer: 'Nichts wartet auf Freigabe.' },
|
||
{ status: 'erledigt', name: 'Erledigt', unter: 'Fertig und abgehakt',
|
||
leer: 'Hier landet, was fertig ist.' },
|
||
];
|
||
const WEITER = { offen: 'arbeit', arbeit: 'review', review: 'erledigt' };
|
||
const ZURUECK = { arbeit: 'offen', review: 'arbeit', erledigt: 'review' };
|
||
|
||
/* Wer darf abbrechen (05.09.2026): *"nur ich die manager und scouts
|
||
sollen auch die aufgaben abbrechen können."* Ein Creator nicht.
|
||
|
||
Dieselbe Menge steht im Server (DARF_ABBRECHEN in
|
||
workspace-aufgaben.js) und DORT sitzt die Sperre. Hier geht es nur
|
||
darum, niemandem einen Knopf hinzustellen, der dann 403 sagt. */
|
||
/* Leitung plus Scouts. Aus der einen Rollenquelle abgeleitet statt
|
||
abgeschrieben -- die abgeschriebene Fassung hatte 'spicy'
|
||
vergessen, und ein fehlender Knopf faellt niemandem auf. */
|
||
const DARF_ABBRECHEN = new Set([...window.Bereiche.LEITUNG, 'scout']);
|
||
const STATUS_NAME = {
|
||
offen: 'Offen', arbeit: 'In Arbeit', review: 'Review', erledigt: 'Erledigt',
|
||
};
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
/* ORTSZEIT, nicht UTC -- sonst gilt nachts eine heute faellige Aufgabe
|
||
noch nicht als faellig, und "Heute faellig" zeigt den Vortag. */
|
||
const heute = window.heuteLokal();
|
||
let ich = null;
|
||
/* Nur beim ERSTEN Laden springen. Sonst reisst es die Seite nach
|
||
jeder Statusaenderung erneut an dieselbe Stelle. */
|
||
let gesprungen = false;
|
||
|
||
/* Texte werden ausschließlich über textContent gesetzt, nie über
|
||
innerHTML -- ein Aufgabentitel darf keine Auszeichnung einschleusen. */
|
||
const el = (tag, klasse, text) => {
|
||
const k = document.createElement(tag);
|
||
if (klasse) k.className = klasse;
|
||
if (text !== undefined) k.textContent = text;
|
||
return k;
|
||
};
|
||
|
||
const melde = (text) => { $('fehler').textContent = text || ''; };
|
||
|
||
async function hole(pfad, optionen = {}) {
|
||
const antwort = await fetch(pfad, { credentials: 'same-origin', ...optionen });
|
||
if (antwort.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
|
||
return antwort;
|
||
}
|
||
|
||
/* ---------- Brett zeichnen ------------------------------------------ */
|
||
|
||
function datumHuebsch(iso) {
|
||
const [j, m, t] = iso.split('-');
|
||
return `${t}.${m}.${j.slice(2)}`;
|
||
}
|
||
|
||
/* ---------- Rückmeldungen ------------------------------------------------
|
||
Direkt an der Karte, nicht in einem Fenster: Man liest sie im
|
||
Zusammenhang mit der Aufgabe, sonst fehlt beim Antworten die Hälfte. */
|
||
|
||
const zeitpunkt = (iso) => {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
const p = (n) => String(n).padStart(2, '0');
|
||
return `${p(d.getDate())}.${p(d.getMonth() + 1)}. · ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||
};
|
||
|
||
async function rueckmeldungen(karteEl, a, knopfEl) {
|
||
const vorhanden = karteEl.querySelector('.rueck');
|
||
if (vorhanden) { vorhanden.remove(); delete knopfEl.dataset.offen; return; }
|
||
knopfEl.dataset.offen = 'ja';
|
||
|
||
const box = el('div', 'rueck');
|
||
box.append(el('p', 'leise', 'Wird geladen …'));
|
||
karteEl.append(box);
|
||
|
||
let daten;
|
||
try {
|
||
const antwort = await hole(`/workspace/api/aufgaben/${a.id}/notizen`);
|
||
if (!antwort.ok) {
|
||
box.textContent = '';
|
||
box.append(el('p', 'fehler', 'Konnte nicht geladen werden.'));
|
||
return;
|
||
}
|
||
daten = await antwort.json();
|
||
} catch { return; }
|
||
|
||
box.textContent = '';
|
||
const liste = el('div', 'rueck__liste');
|
||
if (!daten.notizen.length) liste.append(el('p', 'leise', 'Noch nichts besprochen.'));
|
||
for (const n of daten.notizen) {
|
||
const z = el('div', 'rueck__eintrag');
|
||
const kopf = el('p', 'rueck__kopf');
|
||
kopf.append(el('span', 'rueck__wer', n.von || 'Unbekannt'));
|
||
kopf.append(el('span', 'rueck__wann', zeitpunkt(n.erstellt)));
|
||
/* Die eigene Rückmeldung darf jeder zurücknehmen, fremde nur die
|
||
Leitung -- wer sich vertippt hat, soll das nicht beantragen
|
||
müssen. */
|
||
if (n.person_id === daten.ich || daten.darf_alles_loeschen) {
|
||
const weg = el('button', 'rueck__weg', '×');
|
||
weg.type = 'button';
|
||
weg.title = 'Rückmeldung entfernen';
|
||
weg.setAttribute('aria-label', 'Rückmeldung entfernen');
|
||
weg.addEventListener('click', async () => {
|
||
if (!confirm('Diese Rückmeldung entfernen?')) return;
|
||
const r = await hole('/workspace/api/notizen/' + n.id, { method: 'DELETE' });
|
||
if (!r.ok) { melde('Entfernen hat nicht geklappt.'); return; }
|
||
await laden();
|
||
});
|
||
kopf.append(weg);
|
||
}
|
||
z.append(kopf, el('p', 'rueck__text', n.text));
|
||
liste.append(z);
|
||
}
|
||
box.append(liste);
|
||
|
||
const form = el('form', 'rueck__form');
|
||
const feld = el('textarea', 'rueck__feld');
|
||
feld.rows = 2;
|
||
feld.maxLength = 2000;
|
||
feld.placeholder = 'Rückmeldung schreiben …';
|
||
const senden = el('button', 'knopf knopf--klein', 'Abschicken');
|
||
senden.type = 'submit';
|
||
form.append(feld, senden);
|
||
form.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const text = feld.value.trim();
|
||
if (text.length < 2) return;
|
||
senden.disabled = true;
|
||
try {
|
||
const r = await hole(`/workspace/api/aufgaben/${a.id}/notizen`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text }),
|
||
});
|
||
if (!r.ok) { melde((await r.json().catch(() => ({}))).fehler || 'Ging nicht.'); return; }
|
||
await laden();
|
||
} catch { /* umgeleitet */ } finally { senden.disabled = false; }
|
||
});
|
||
box.append(form);
|
||
feld.focus();
|
||
}
|
||
|
||
function karte(a) {
|
||
const k = el('article', 'karte');
|
||
k.dataset.prio = a.prioritaet;
|
||
|
||
const kopf = el('div', 'karte__kopf');
|
||
kopf.append(el('h3', 'karte__titel', a.titel));
|
||
const stift = el('button', 'karte__stift', 'bearbeiten');
|
||
stift.type = 'button';
|
||
stift.setAttribute('aria-label', `${a.titel} bearbeiten`);
|
||
stift.addEventListener('click', () => dialogOeffnen(a));
|
||
kopf.append(stift);
|
||
k.append(kopf);
|
||
if (a.beschreibung) k.append(el('p', 'karte__text', a.beschreibung));
|
||
|
||
const zeile = el('div', 'karte__zeile');
|
||
/* 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'));
|
||
}
|
||
if (a.frist) {
|
||
const faellig = a.status !== 'erledigt' && a.frist < heute;
|
||
const f = el('span', 'karte__frist' + (faellig ? ' karte__frist--faellig' : ''),
|
||
(faellig ? 'überfällig ' : 'bis ') + datumHuebsch(a.frist));
|
||
zeile.append(f);
|
||
}
|
||
if (a.verantwortlich_name) zeile.append(el('span', null, '· ' + a.verantwortlich_name));
|
||
if (LEITUNG.has(ich.rolle) && a.creator_name && a.creator_name !== a.verantwortlich_name) {
|
||
zeile.append(el('span', null, '· Bereich ' + a.creator_name));
|
||
}
|
||
if (zeile.childElementCount) k.append(zeile);
|
||
|
||
const schritte = el('div', 'karte__schritte');
|
||
if (ZURUECK[a.status]) schritte.append(knopf('◀ zurück', a, ZURUECK[a.status]));
|
||
if (WEITER[a.status]) schritte.append(knopf(beschriftung(a.status), a, WEITER[a.status]));
|
||
/* ABBRECHEN — in JEDEM Status (05.09.2026), auch im erledigten:
|
||
Auch etwas Abgehaktes kann sich als hinfällig herausstellen.
|
||
|
||
Nur DogFather, Manager und Scouts. Ein Creator sieht den Knopf
|
||
gar nicht erst -- die eigentliche Sperre sitzt aber im Server,
|
||
hier geht es nur darum, niemandem etwas anzubieten, das dann
|
||
abgelehnt wird. */
|
||
if (DARF_ABBRECHEN.has(ich.rolle)) {
|
||
const ab = el('button', 'schritt schritt--ab', 'abbrechen');
|
||
ab.type = 'button';
|
||
ab.setAttribute('aria-label', `„${a.titel}" abbrechen`);
|
||
ab.addEventListener('click', () => abbruchOeffnen(a));
|
||
schritte.append(ab);
|
||
}
|
||
if (schritte.childElementCount) k.append(schritte);
|
||
|
||
/* Rückmeldungen ("Aufgaben & Feedback" aus dem Konzept). Der Knopf
|
||
zeigt gleich die Anzahl -- so sieht man ohne Aufklappen, wo etwas
|
||
besprochen wurde. */
|
||
const rueck = el('button', 'karte__rueck');
|
||
rueck.type = 'button';
|
||
rueck.dataset.hat = a.notizen ? 'ja' : 'nein';
|
||
const blase = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||
blase.setAttribute('viewBox', '0 0 24 24');
|
||
blase.setAttribute('class', 'karte__blase');
|
||
blase.setAttribute('aria-hidden', 'true');
|
||
blase.innerHTML = '<path d="M20.4 12.4c0 3.8-3.8 6.9-8.4 6.9-1 0-2-.15-2.9-.42L4 20.4l1.6-3.6a6.6 6.6 0 0 1-2-4.4C3.6 8.6 7.4 5.5 12 5.5s8.4 3.1 8.4 6.9z"/>';
|
||
rueck.append(blase, el('span', null, a.notizen
|
||
? `${a.notizen} ${a.notizen === 1 ? 'Rückmeldung' : 'Rückmeldungen'}`
|
||
: 'Rückmeldung schreiben'));
|
||
rueck.addEventListener('click', () => rueckmeldungen(k, a, rueck));
|
||
k.append(rueck);
|
||
return k;
|
||
}
|
||
|
||
/* ---------- Abbrechen ----------------------------------------------------
|
||
|
||
Wunsch 05.09.2026: *"ich will dass man in jedem status die aufgaben
|
||
auch abbrechen kann."*
|
||
|
||
MIT PFLICHT-GRUND. Ohne ihn weiss in vier Wochen niemand mehr,
|
||
warum etwas wegfiel -- und dann ist ein Abbruch von einem Loeschen
|
||
nicht mehr zu unterscheiden. Der Grund steht spaeter an der
|
||
Aufgabe, zusammen mit dem Namen dessen, der abgebrochen hat.
|
||
|
||
Ein <dialog> und kein confirm(): confirm blockiert die ganze Seite,
|
||
nimmt kein Textfeld auf und sieht auf jedem Rechner anders aus. */
|
||
|
||
function abbruchOeffnen(a) {
|
||
const dlg = $('abbruch-dialog');
|
||
if (!dlg) return;
|
||
dlg.dataset.id = String(a.id);
|
||
$('abbruch-titel').textContent = `„${a.titel}" abbrechen?`;
|
||
/* Sagt, was gleich passiert -- und was NICHT passiert. Die Sorge
|
||
bei so einem Knopf ist immer, ob etwas verloren geht. */
|
||
$('abbruch-was').textContent = `Die Aufgabe steht danach unter „Abgebrochen" `
|
||
+ `statt in der Spalte ${STATUS_NAME[a.status] || a.status}. `
|
||
+ `Sie wird nicht gelöscht und lässt sich jederzeit wieder aufnehmen.`;
|
||
$('abbruch-grund').value = '';
|
||
$('abbruch-fehler').textContent = '';
|
||
dlg.showModal();
|
||
$('abbruch-grund').focus();
|
||
}
|
||
|
||
async function abbruchSenden(e) {
|
||
e.preventDefault();
|
||
const dlg = $('abbruch-dialog');
|
||
const id = Number(dlg.dataset.id);
|
||
const grund = $('abbruch-grund').value.trim();
|
||
if (grund.length < 3) {
|
||
$('abbruch-fehler').textContent = 'Bitte kurz sagen, warum.';
|
||
$('abbruch-grund').focus();
|
||
return;
|
||
}
|
||
const knopf = $('abbruch-ok');
|
||
knopf.disabled = true;
|
||
try {
|
||
const antwort = await hole(`/workspace/api/aufgaben/${id}/abbrechen`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ grund }),
|
||
});
|
||
if (!antwort.ok) {
|
||
const d = await antwort.json().catch(() => ({}));
|
||
$('abbruch-fehler').textContent = d.fehler || 'Hat nicht geklappt.';
|
||
return;
|
||
}
|
||
dlg.close();
|
||
await laden();
|
||
} catch { /* umgeleitet */ } finally { knopf.disabled = false; }
|
||
}
|
||
|
||
async function wiederAufnehmen(a, b) {
|
||
b.disabled = true;
|
||
melde('');
|
||
try {
|
||
const antwort = await hole(`/workspace/api/aufgaben/${a.id}/wiederaufnehmen`,
|
||
{ method: 'POST' });
|
||
if (!antwort.ok) {
|
||
const d = await antwort.json().catch(() => ({}));
|
||
melde(d.fehler || 'Hat nicht geklappt.');
|
||
b.disabled = false;
|
||
return;
|
||
}
|
||
await laden();
|
||
} catch { b.disabled = false; }
|
||
}
|
||
|
||
/** Der Bereich UNTER dem Brett -- zugeklappt, mit Anzahl im Titel.
|
||
*
|
||
* Bewusst keine fuenfte Spalte: Am Handy werden dadurch alle fuenf
|
||
* so schmal, dass keine mehr lesbar ist. Und abgebrochene Aufgaben
|
||
* sind nichts, was man taeglich sieht -- aber sie duerfen auch nicht
|
||
* verschwinden, sonst waere es Loeschen mit Zwischenschritt. */
|
||
function abgebrocheneZeigen(alle) {
|
||
const kasten = $('abgebrochen');
|
||
if (!kasten) return;
|
||
const eigene = alle.filter((a) => a.status === 'abgebrochen');
|
||
|
||
if (!eigene.length) { kasten.hidden = true; return; }
|
||
kasten.hidden = false;
|
||
|
||
$('abgebrochen-zahl').textContent = String(eigene.length);
|
||
const liste = $('abgebrochen-liste');
|
||
liste.textContent = '';
|
||
|
||
for (const a of eigene) {
|
||
const k = el('article', 'abbruch-karte');
|
||
const kopf = el('div', 'abbruch-karte__kopf');
|
||
kopf.append(el('h3', 'abbruch-karte__titel', a.titel));
|
||
/* Wo es stand, als es abgebrochen wurde. "Im Review abgebrochen"
|
||
ist eine andere Aussage als "nie angefangen". */
|
||
if (a.status_vorher) {
|
||
kopf.append(el('span', 'abbruch-karte__vorher',
|
||
STATUS_NAME[a.status_vorher] || a.status_vorher));
|
||
}
|
||
k.append(kopf);
|
||
|
||
if (a.abbruch_grund) k.append(el('p', 'abbruch-karte__grund', a.abbruch_grund));
|
||
|
||
const wer = [];
|
||
if (a.abbruch_von_name) wer.push(a.abbruch_von_name);
|
||
if (a.abgebrochen_am) wer.push(datumHuebsch(a.abgebrochen_am.slice(0, 10)));
|
||
if (wer.length) k.append(el('p', 'abbruch-karte__wer', wer.join(' · ')));
|
||
|
||
if (DARF_ABBRECHEN.has(ich.rolle)) {
|
||
const zurueck = el('button', 'schritt', 'wieder aufnehmen ↩');
|
||
zurueck.type = 'button';
|
||
zurueck.setAttribute('aria-label', `„${a.titel}" wieder aufnehmen`);
|
||
zurueck.addEventListener('click', () => wiederAufnehmen(a, zurueck));
|
||
k.append(zurueck);
|
||
}
|
||
liste.append(k);
|
||
}
|
||
}
|
||
|
||
const beschriftung = (status) => ({
|
||
offen: 'starten ▶', arbeit: 'zum Review ▶', review: 'erledigt ▶',
|
||
})[status];
|
||
|
||
function knopf(text, aufgabe, neuerStatus) {
|
||
const b = el('button', 'schritt', text);
|
||
b.type = 'button';
|
||
b.addEventListener('click', async () => {
|
||
b.disabled = true;
|
||
melde('');
|
||
try {
|
||
const antwort = await hole(`/workspace/api/aufgaben/${aufgabe.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ status: neuerStatus }),
|
||
});
|
||
if (!antwort.ok) {
|
||
const d = await antwort.json().catch(() => ({}));
|
||
melde(d.fehler === 'nicht_erlaubt'
|
||
? 'Diese Aufgabe darfst du nicht ändern.'
|
||
: 'Ändern hat nicht geklappt.');
|
||
b.disabled = false;
|
||
return;
|
||
}
|
||
await laden();
|
||
} catch { b.disabled = false; }
|
||
});
|
||
return b;
|
||
}
|
||
|
||
/* ---------- Die Leiste ueber dem Brett -----------------------------------
|
||
|
||
Wunsch vom 01.09.2026: "verbessere die Aufgabenseite auch bei
|
||
Manager, Scout und Creator. Die Scouts und Manager sollen vielleicht
|
||
ein bisschen mehr Optionen haben, wie ihre zugeteilten Creator.
|
||
Aber auch die Creator sollen es besser und detaillierter sehen."
|
||
|
||
Drei Dinge, und die Reihenfolge ist die Aussage:
|
||
|
||
1. WAS BRENNT. Ein Satz beim Reinkommen, ob etwas ueberfaellig ist.
|
||
Ein Brett mit vier Spalten beantwortet das nicht -- man muss vier
|
||
Spalten durchsehen, um zu wissen, ob man ruhig anfangen kann.
|
||
|
||
2. AUSSCHNITTE. "Nur meine", "Heute faellig", "Ueberfaellig" -- die
|
||
Fragen, die man wirklich hat. Sie filtern das Brett, statt eine
|
||
zweite Liste aufzumachen: Vier Spalten nebeneinander sind der
|
||
Wert dieser Seite, den nimmt man nicht weg.
|
||
|
||
3. DIE CREATOR, nur fuer Betreuer. Wer drei betreut, will sehen,
|
||
was bei einem davon liegt -- ohne die anderen zwei dazwischen.
|
||
Ein Creator bekommt diese Reihe nicht: Er hat nur sich, und ein
|
||
Filter mit einem einzigen Eintrag ist ein Knopf, der nichts tut.
|
||
|
||
Alles hier ist ANSICHT. Wer was sehen darf, hat der Server
|
||
entschieden, bevor eine einzige Aufgabe hier ankommt. */
|
||
|
||
let ausschnitt = ''; /* '', 'meine', 'faellig', 'heute' */
|
||
let nurCreator = 0; /* 0 = alle */
|
||
let alleAufgaben = [];
|
||
|
||
/* Abgebrochenes zählt NICHT als offen (05.09.2026). Ohne diese
|
||
Ergänzung stünde eine abgebrochene Aufgabe weiter in "3 Aufgaben
|
||
stehen an" und in "Überfällig" -- und würde als etwas gemahnt, das
|
||
ausdrücklich niemand mehr tun soll. */
|
||
const istOffen = (a) => a.status !== 'erledigt' && a.status !== 'abgebrochen';
|
||
const istFaellig = (a) => istOffen(a) && a.frist && a.frist < heute;
|
||
const istHeute = (a) => istOffen(a) && a.frist === heute;
|
||
const istMeine = (a) => a.verantwortlich_id === ich.id;
|
||
|
||
function passt(a) {
|
||
if (nurCreator && a.creator_id !== nurCreator) return false;
|
||
if (ausschnitt === 'meine') return istMeine(a);
|
||
if (ausschnitt === 'faellig') return istFaellig(a);
|
||
if (ausschnitt === 'heute') return istHeute(a);
|
||
return true;
|
||
}
|
||
|
||
function lageZeigen(alle) {
|
||
const ziel = $('lage');
|
||
if (!ziel) return;
|
||
const faellig = alle.filter(istFaellig).length;
|
||
const heuteN = alle.filter(istHeute).length;
|
||
const offen = alle.filter(istOffen).length;
|
||
const meine = alle.filter((a) => istOffen(a) && istMeine(a)).length;
|
||
|
||
ziel.textContent = '';
|
||
let satz, stufe;
|
||
if (faellig) {
|
||
satz = `${faellig} ${faellig === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} überfällig – das zuerst.`;
|
||
stufe = 'warnung';
|
||
} else if (heuteN) {
|
||
satz = `${heuteN} ${heuteN === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} heute fällig.`;
|
||
stufe = 'offen';
|
||
} else if (offen) {
|
||
satz = `${offen} ${offen === 1 ? 'Aufgabe steht' : 'Aufgaben stehen'} an, nichts davon brennt.`;
|
||
stufe = 'offen';
|
||
} else {
|
||
satz = 'Nichts offen – alles abgearbeitet.';
|
||
stufe = 'ruhig';
|
||
}
|
||
ziel.dataset.stufe = stufe;
|
||
ziel.append(el('span', 'lage__satz', satz));
|
||
/* Die eigene Zahl steht daneben, nicht im Satz: Auf einem Brett, das
|
||
auch fremde Aufgaben zeigt, ist "wie viel davon ist meins" die
|
||
zweite Frage -- aber eben die zweite. */
|
||
if (ich.rolle !== 'creator' && meine) {
|
||
ziel.append(el('span', 'lage__meins', `${meine} davon bei dir`));
|
||
}
|
||
ziel.hidden = false;
|
||
}
|
||
|
||
function ausschnitteBauen(alle) {
|
||
const ziel = $('ausschnitte');
|
||
if (!ziel) return;
|
||
ziel.textContent = '';
|
||
|
||
const knopf = (wert, name, anzahl) => {
|
||
const b = el('button', 'schnitt');
|
||
b.type = 'button';
|
||
b.dataset.wert = wert;
|
||
b.setAttribute('aria-pressed', String(ausschnitt === wert));
|
||
b.append(el('span', null, name));
|
||
/* Die Zahl steht am Knopf. Ein Filter, der sich erst nach dem
|
||
Klick als leer herausstellt, kostet zweimal Aufmerksamkeit. */
|
||
b.append(el('span', 'schnitt__zahl', String(anzahl)));
|
||
if (!anzahl && wert) b.disabled = true;
|
||
b.addEventListener('click', () => {
|
||
ausschnitt = ausschnitt === wert ? '' : wert;
|
||
neuZeichnen();
|
||
});
|
||
return b;
|
||
};
|
||
|
||
ziel.append(knopf('', 'Alle', alle.length));
|
||
if (ich.rolle !== 'creator') {
|
||
ziel.append(knopf('meine', 'Nur meine',
|
||
alle.filter((a) => istOffen(a) && istMeine(a)).length));
|
||
}
|
||
ziel.append(knopf('heute', 'Heute fällig', alle.filter(istHeute).length));
|
||
const f = knopf('faellig', 'Überfällig', alle.filter(istFaellig).length);
|
||
f.dataset.warn = 'ja';
|
||
ziel.append(f);
|
||
ziel.hidden = false;
|
||
}
|
||
|
||
function creatorReiheBauen(alle) {
|
||
const ziel = $('creator-reihe');
|
||
if (!ziel) return;
|
||
ziel.textContent = '';
|
||
if (ich.rolle === 'creator') { ziel.hidden = true; return; }
|
||
|
||
/* Die Liste kommt aus den Aufgaben selbst, nicht aus einer
|
||
Personenabfrage: Dann stehen dort genau die Creator, die man
|
||
ohnehin sehen darf -- und niemals einer mehr. */
|
||
const nach = new Map();
|
||
for (const a of alle) {
|
||
if (!a.creator_id || !a.creator_name) continue;
|
||
const w = nach.get(a.creator_id) || { name: a.creator_name, offen: 0, faellig: 0 };
|
||
if (istOffen(a)) w.offen++;
|
||
if (istFaellig(a)) w.faellig++;
|
||
nach.set(a.creator_id, w);
|
||
}
|
||
/* Unter zwei Creatorn ist die Reihe eine Zeile ohne Auswahl. */
|
||
if (nach.size < 2) { ziel.hidden = true; return; }
|
||
|
||
/* "Deine Creator" stimmt fuer Scout und Manager -- bei DogFather
|
||
waeren es ALLE, und "deine" waere dort eine falsche Auskunft. */
|
||
ziel.append(el('span', 'creator-reihe__schild',
|
||
ich.rolle === 'admin' ? 'Alle Creator' : 'Deine Creator'));
|
||
const knopf = (id, name, offen, faellig) => {
|
||
const b = el('button', 'creator-knopf');
|
||
b.type = 'button';
|
||
b.setAttribute('aria-pressed', String(nurCreator === id));
|
||
b.append(el('span', 'creator-knopf__name', name));
|
||
b.append(el('span', 'creator-knopf__zahl', String(offen)));
|
||
if (faellig) {
|
||
const m = el('span', 'creator-knopf__warn', String(faellig));
|
||
m.title = `${faellig} überfällig`;
|
||
b.append(m);
|
||
}
|
||
b.addEventListener('click', () => {
|
||
nurCreator = nurCreator === id ? 0 : id;
|
||
neuZeichnen();
|
||
});
|
||
return b;
|
||
};
|
||
ziel.append(knopf(0, 'Alle', alle.filter(istOffen).length, alle.filter(istFaellig).length));
|
||
for (const [id, w] of [...nach.entries()].sort((a, b) => b[1].offen - a[1].offen)) {
|
||
ziel.append(knopf(id, w.name, w.offen, w.faellig));
|
||
}
|
||
ziel.hidden = false;
|
||
}
|
||
|
||
function neuZeichnen() {
|
||
ausschnitteBauen(alleAufgaben);
|
||
creatorReiheBauen(alleAufgaben);
|
||
zeichne(alleAufgaben.filter(passt));
|
||
/* Der Bereich folgt dem Creator-Filter mit -- wer sich eine Person
|
||
herausgreift, will auch nur deren Abgebrochenes sehen. Die
|
||
Ausschnitte (heute/überfällig) gelten dagegen nicht: Sie fragen
|
||
nach Fristen, und eine abgebrochene Aufgabe hat keine mehr. */
|
||
abgebrocheneZeigen(nurCreator
|
||
? alleAufgaben.filter((a) => a.creator_id === nurCreator)
|
||
: alleAufgaben);
|
||
}
|
||
|
||
function zeichne(aufgaben) {
|
||
const brett = $('brett');
|
||
brett.textContent = '';
|
||
brett.setAttribute('aria-busy', 'false');
|
||
|
||
for (const s of SPALTEN) {
|
||
const eigene = aufgaben.filter((a) => a.status === s.status);
|
||
const spalte = el('section', 'spalte');
|
||
spalte.dataset.status = s.status;
|
||
/* Die Karte merkt sich, wozu sie gehoert -- daraus findet der
|
||
Sprung vom Bericht die richtigen wieder, ohne die Liste ein
|
||
zweites Mal durchzugehen. */
|
||
|
||
const kopf = el('h2', 'spalte__kopf');
|
||
kopf.append(el('span', 'spalte__punkt'), el('span', null, s.name),
|
||
el('span', 'spalte__zahl', String(eigene.length)));
|
||
spalte.append(kopf);
|
||
/* Was diese Spalte bedeutet -- steht immer da, nicht nur wenn sie
|
||
leer ist. "Review" allein sagt einem Neuen nichts. */
|
||
spalte.append(el('p', 'spalte__unter', s.unter));
|
||
|
||
if (!eigene.length) spalte.append(el('p', 'spalte__leer', s.leer));
|
||
else for (const a of eigene) {
|
||
const k = karte(a);
|
||
k.dataset.status = a.status;
|
||
if (a.status !== 'erledigt' && a.frist && a.frist < heute) k.dataset.faellig = 'ja';
|
||
/* "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() {
|
||
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]);
|
||
}
|
||
|
||
async function laden() {
|
||
try {
|
||
const antwort = await hole('/workspace/api/aufgaben');
|
||
if (!antwort.ok) { melde('Aufgaben konnten nicht geladen werden.'); return; }
|
||
alleAufgaben = (await antwort.json()).aufgaben || [];
|
||
lageZeigen(alleAufgaben);
|
||
neuZeichnen();
|
||
/* Erst NACH dem Zeichnen -- vorher gibt es die Karten nicht, und
|
||
der Sprung ginge ins Leere, ohne dass jemand etwas merkt. Nur
|
||
beim ersten Laden: Sonst springt die Seite nach jeder
|
||
Statusaenderung erneut weg. */
|
||
if (!gesprungen) { gesprungen = true; sprungZeigen(); }
|
||
} catch { /* bereits umgeleitet */ }
|
||
}
|
||
|
||
/* ---------- Neue Aufgabe -------------------------------------------- */
|
||
|
||
function formularUmschalten(auf) {
|
||
$('neu').hidden = !auf;
|
||
$('neu-oeffnen').hidden = auf;
|
||
if (auf) $('f-titel').focus();
|
||
}
|
||
|
||
$('neu-oeffnen').addEventListener('click', () => formularUmschalten(true));
|
||
$('neu-abbrechen').addEventListener('click', () => {
|
||
$('neu').reset(); $('neu-fehler').textContent = ''; formularUmschalten(false);
|
||
});
|
||
|
||
$('neu').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const knopfEl = $('neu-speichern');
|
||
$('neu-fehler').textContent = '';
|
||
|
||
const daten = {
|
||
titel: $('f-titel').value,
|
||
beschreibung: $('f-text').value,
|
||
prioritaet: $('f-prio').value,
|
||
frist: $('f-frist').value,
|
||
};
|
||
if (LEITUNG.has(ich.rolle)) {
|
||
daten.creator_id = $('f-creator').value || null;
|
||
daten.verantwortlich_id = $('f-verant').value || null;
|
||
daten.creator_extern = $('f-creator').dataset.extern || null;
|
||
daten.verantwortlich_extern = $('f-verant').dataset.extern || null;
|
||
}
|
||
|
||
knopfEl.disabled = true;
|
||
try {
|
||
const antwort = await hole('/workspace/api/aufgaben', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(daten),
|
||
});
|
||
if (!antwort.ok) {
|
||
const d = await antwort.json().catch(() => ({}));
|
||
$('neu-fehler').textContent = d.fehler || 'Anlegen hat nicht geklappt.';
|
||
return;
|
||
}
|
||
$('neu').reset();
|
||
formularUmschalten(false);
|
||
await laden();
|
||
} catch { /* bereits umgeleitet */ } finally { knopfEl.disabled = false; }
|
||
});
|
||
|
||
/* ---------- Bearbeiten ------------------------------------------------ */
|
||
|
||
const dialog = $('bearbeiten-dialog');
|
||
let bearbeitet = null;
|
||
|
||
function dialogOeffnen(a) {
|
||
bearbeitet = a;
|
||
$('b-titel').value = a.titel;
|
||
$('b-text').value = a.beschreibung || '';
|
||
$('b-prio').value = a.prioritaet;
|
||
$('b-frist').value = a.frist || '';
|
||
$('b-fehler').textContent = '';
|
||
if (LEITUNG.has(ich.rolle)) {
|
||
$('b-creator').value = a.creator_id ? String(a.creator_id) : '';
|
||
$('b-verant').value = a.verantwortlich_id ? String(a.verantwortlich_id) : '';
|
||
$('b-loeschen').hidden = false;
|
||
}
|
||
dialog.showModal();
|
||
$('b-titel').focus();
|
||
}
|
||
|
||
$('b-abbrechen').addEventListener('click', () => dialog.close());
|
||
|
||
/* Der Abbruch-Dialog. Am submit des Formulars und nicht am Knopf:
|
||
So löst auch Enter im Textfeld aus, und Esc schließt von selbst
|
||
(beides bringt <dialog> mit, wenn man es lässt). */
|
||
$('abbruch-form').addEventListener('submit', abbruchSenden);
|
||
$('abbruch-zurueck').addEventListener('click', () => $('abbruch-dialog').close());
|
||
|
||
$('b-speichern').addEventListener('click', async () => {
|
||
const daten = {
|
||
titel: $('b-titel').value,
|
||
beschreibung: $('b-text').value,
|
||
prioritaet: $('b-prio').value,
|
||
frist: $('b-frist').value,
|
||
};
|
||
if (LEITUNG.has(ich.rolle)) {
|
||
daten.creator_id = $('b-creator').value || null;
|
||
daten.verantwortlich_id = $('b-verant').value || null;
|
||
daten.creator_extern = $('b-creator').dataset.extern || null;
|
||
daten.verantwortlich_extern = $('b-verant').dataset.extern || null;
|
||
}
|
||
$('b-speichern').disabled = true;
|
||
try {
|
||
const a = await hole(`/workspace/api/aufgaben/${bearbeitet.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(daten),
|
||
});
|
||
if (!a.ok) {
|
||
$('b-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Speichern ging nicht.';
|
||
return;
|
||
}
|
||
dialog.close();
|
||
await laden();
|
||
} catch { /* umgeleitet */ } finally { $('b-speichern').disabled = false; }
|
||
});
|
||
|
||
$('b-loeschen').addEventListener('click', async () => {
|
||
if (!confirm(`Aufgabe wirklich loeschen?
|
||
|
||
"${bearbeitet.titel}"
|
||
|
||
Das laesst sich nicht rueckgaengig machen. Erledigte Aufgaben sollten normalerweise stehen bleiben.`)) return;
|
||
try {
|
||
const a = await hole(`/workspace/api/aufgaben/${bearbeitet.id}`, { method: 'DELETE' });
|
||
if (!a.ok) { $('b-fehler').textContent = 'Loeschen ging nicht.'; return; }
|
||
dialog.close();
|
||
await laden();
|
||
} catch { /* umgeleitet */ }
|
||
});
|
||
|
||
/* ---------- Start ----------------------------------------------------- */
|
||
|
||
|
||
(async () => {
|
||
try {
|
||
const antwort = await hole('/workspace/api/ich');
|
||
if (!antwort.ok) { location.assign('/workspace/'); return; }
|
||
ich = await antwort.json();
|
||
} catch { return; }
|
||
|
||
window.werZeigen?.(ich);
|
||
|
||
if (LEITUNG.has(ich.rolle)) {
|
||
$('feld-creator').hidden = false;
|
||
$('feld-verant').hidden = false;
|
||
$('b-feld-creator').hidden = false;
|
||
$('b-feld-verant').hidden = false;
|
||
try {
|
||
const { personen } = await (await hole('/workspace/api/personen')).json();
|
||
for (const p of personen) {
|
||
for (const ziel of ['f-creator', 'f-verant', 'b-creator', 'b-verant']) {
|
||
const o = el('option', null, `${p.name} (${p.rolle})`);
|
||
o.value = String(p.id);
|
||
/* Bild ans <option> -- wahl.js zeichnet daraus das Gesicht.
|
||
Diese zwei Zeilen standen beim ersten Anlauf am DATEIENDE,
|
||
ausserhalb der Schleife: `o is not defined`, gemeldet von
|
||
pruef-sicht, das die Browserkonsole mitliest. Ein
|
||
Suchmuster ueber mehrere Dateien trifft eben nicht immer
|
||
die Stelle, die man meint. */
|
||
o.dataset.person = 'ja';
|
||
if (p.bild) o.dataset.bild = p.bild;
|
||
$(ziel).append(o);
|
||
}
|
||
}
|
||
} catch { /* Zuordnung bleibt dann leer -- kein Grund abzubrechen */ }
|
||
/* Aussuchen ODER selbst eintragen (02.09.2026). Der Hinweis
|
||
erscheint erst, wenn wirklich ein freier Name drinsteht -- ein
|
||
dauerhafter Warnsatz wird nach zwei Tagen überlesen. */
|
||
for (const ziel of ['f-creator', 'b-creator']) {
|
||
window.personenwahl?.(ziel, { hinweis: "Kein Konto im Workspace – der Eintrag gehört dann zu keiner Person und taucht in personenbezogenen Auswertungen nicht auf. Sichtbar bleibt er für dich." });
|
||
}
|
||
for (const ziel of ['f-verant', 'b-verant']) {
|
||
window.personenwahl?.(ziel, { hinweis: "Kein Konto im Workspace – der Name steht nur an diesem Eintrag."
|
||
+ ' Die Aufgabe steht dann auf keinem persönlichen Aufgabenbrett.' });
|
||
}
|
||
}
|
||
|
||
await laden();
|
||
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;
|
||
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;
|
||
if (!vorlagen) {
|
||
try {
|
||
const a = await hole('/workspace/api/vorlagen');
|
||
if (!a.ok) return;
|
||
vorlagen = (await a.json()).aufgaben || null;
|
||
} catch { 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);
|
||
}
|
||
}
|
||
|
||
/* 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(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;
|
||
}
|
||
}
|
||
})();
|