Files
dogfather-universe/workspace/assets/js/aufgaben.js
T
DogFatherGitandClaude Opus 5 8647138bb4 Leere Seiten sehen nicht mehr kaputt aus
Die vier Aufgaben-Spalten waren vier graue Kaesten mit je einem
Gedankenstrich darin. Das sieht aus, als sei die Seite kaputt -- dabei
ist eine leere Spalte ein voellig normaler, oft sogar guter Zustand.

Jetzt hat jede Spalte ihre eigene Farbe nach STATUS (offen blaugrau,
in Arbeit blau, Review gold, erledigt gruen), einen auslaufenden
Streifen oben in dieser Farbe und eine Zeile, die erklaert, was die
Spalte ueberhaupt bedeutet -- "Review" allein sagt einem Neuen nichts.
Statt des Gedankenstrichs steht ein Satz, der die AUSSAGE des
Leerseins traegt: eine leere Review-Spalte bedeutet etwas anderes als
eine leere Erledigt-Spalte.

NEUN KOPIEN DERSELBEN REGEL. .leer-hinweis war in neun CSS-Dateien
definiert -- neunmal fast dasselbe, mit Abstaenden von 22, 26 und 30 px,
weil beim Kopieren jedes Mal etwas anders wurde. Genau davor warnt ein
Kommentar in start.css seit August ("Was auf mehreren Seiten benutzt
wird, gehoert hierher"). Jetzt einmal zentral, und jede Seite hat sie.

Und sie sieht anders aus: Der gestrichelte Rand ist weg. Gestrichelte
Raender sagen "hier fehlt etwas", grau auf grau sagt "unwichtig" --
zusammen also "kaputte Seite". Stattdessen eine ruhige, geschlossene
Flaeche im Farbton der Seite (aus data-ton, also aus der Kachelfarbe)
mit einem leuchtenden Ring. Ein Zeichen waere hier zu laut: Es geht ja
gerade darum, dass nichts da ist -- der Ring markiert die Stelle, ohne
etwas zu behaupten.

Kontrast danach nachgemessen: 4,85:1 im schlechtesten Fall.

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

406 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ===================================================================
Aufgabenbrett.
Der Server entscheidet, wer was sehen und ändern darf -- diese Datei
zeigt nur an. Was hier ausgeblendet wird, ist Bequemlichkeit, kein
Schutz.
=================================================================== */
(() => {
'use strict';
/* DogFather und Manager haben dieselben Rechte -- die Unterscheidung
macht nur die Personenverwaltung, und die prueft der Server. */
const LEITUNG = new Set(['admin', 'manager']);
/* `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' };
const $ = (id) => document.getElementById(id);
const heute = new Date().toISOString().slice(0, 10);
let ich = null;
/* 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');
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]));
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;
}
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;
}
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;
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) spalte.append(karte(a));
brett.append(spalte);
}
}
async function laden() {
try {
const antwort = await hole('/workspace/api/aufgaben');
if (!antwort.ok) { melde('Aufgaben konnten nicht geladen werden.'); return; }
zeichne((await antwort.json()).aufgaben);
} 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;
}
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());
$('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;
}
$('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);
$(ziel).append(o);
}
}
} catch { /* Zuordnung bleibt dann leer -- kein Grund abzubrechen */ }
}
await laden();
})();
})();