From 63aea61f39949911c9b8616223e65e0d7622f71b Mon Sep 17 00:00:00 2001 From: Dogfather Date: Mon, 31 Aug 2026 12:37:50 +0200 Subject: [PATCH] Aufgaben: Rueckmeldungen ("Aufgaben & Feedback") Letzter offener Punkt aus dem Konzept, Seite 4: Ein Creator hat laut Rollenbeschreibung "Aufgaben & Feedback" -- die Aufgaben gab es, das Feedback nicht. Ohne diese Moeglichkeit endet jede Rueckfrage ausserhalb des Systems, in WhatsApp, und damit ausserhalb dessen, was spaeter noch nachvollziehbar ist. Neue Tabelle aufgaben_notizen. Wer die Aufgabe sehen darf, darf auch mitreden -- geprueft ueber DIESELBE Sichtbarkeitsregel wie fuer die Aufgabe selbst. Eine eigene Regel gibt es bewusst nicht: Zwei Regeln fuer dieselbe Sache laufen irgendwann auseinander, und die Aufgaben- sichtbarkeit ist die schwierigere von beiden (Creator sieht eigene, Scout die seiner betreuten Creator, Leitung alles). 404 statt 403, wenn die Aufgabe nicht sichtbar ist -- wer sie nicht sehen darf, soll nicht erfahren, dass es sie gibt. Die eigene Rueckmeldung darf jeder zuruecknehmen, fremde nur die Leitung. Eine Rueckmeldung ist keine Abstimmung: Wer sich vertippt hat, soll das nicht bei jemandem beantragen muessen. Der Knopf auf der Karte zeigt die ANZAHL, nicht nur ein Symbol -- so sieht man ohne Aufklappen, wo schon etwas besprochen wurde, und die Karte bleibt trotzdem ruhig. Aufgeklappt wird direkt an der Karte, nicht in einem Fenster: Man liest die Rueckmeldungen im Zusammenhang mit der Aufgabe, sonst fehlt beim Antworten die Haelfte. Geprueft: - Chef und Luna schreiben sich gegenseitig, beide sehen beides - Mika (fremder Creator) bekommt 404 beim Lesen UND beim Schreiben - ohne Anmeldung 401, fremde Herkunft 403, leerer Text abgewiesen - Luna kann Chefs Rueckmeldung nicht loeschen, ihre eigene schon - Chef kann alle loeschen - Zaehler auf der Karte stimmt, Handy ohne Ueberlauf, 0 Konsolenfehler --- server/workspace-aufgaben.js | 91 +++++++++++++++++++++++++- server/workspace.js | 17 +++++ workspace/assets/css/aufgaben.css | 67 +++++++++++++++++++ workspace/assets/js/aufgaben.js | 104 ++++++++++++++++++++++++++++++ workspace/aufgaben.html | 12 ++-- workspace/bereich.html | 14 ++-- workspace/calls.html | 16 ++--- workspace/dateien.html | 14 ++-- workspace/index.html | 4 +- workspace/kalender.html | 14 ++-- workspace/personen.html | 14 ++-- workspace/profil.html | 14 ++-- workspace/report.html | 16 ++--- workspace/scouting.html | 14 ++-- workspace/start.html | 10 +-- workspace/startcheck.html | 16 ++--- workspace/wissen.html | 14 ++-- 17 files changed, 364 insertions(+), 87 deletions(-) diff --git a/server/workspace-aufgaben.js b/server/workspace-aufgaben.js index 5c03a198..91f2e8d6 100644 --- a/server/workspace-aufgaben.js +++ b/server/workspace-aufgaben.js @@ -91,7 +91,9 @@ aufgabenRouter.get("/workspace/api/aufgaben", (req, res) => { try { const { wo, werte } = sichtbar(req.person); const reihen = db().prepare(` - SELECT ${SPALTEN} ${VERBUND} + SELECT ${SPALTEN}, + (SELECT COUNT(*) FROM aufgaben_notizen n WHERE n.aufgabe_id = a.id) AS notizen + ${VERBUND} WHERE ${wo} ORDER BY CASE a.prioritaet WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END, @@ -186,6 +188,93 @@ function pruefeFelder(körper, { neu }) { return { aus, fehler }; } +/* ---------- Rueckmeldungen ("Aufgaben & Feedback") ------------------------ + Wer die Aufgabe sieht, darf mitreden. Geprueft wird das ueber dieselbe + Sichtbarkeitsregel wie fuer die Aufgabe selbst -- eine zweite Regel + waere eine zweite Stelle, an der es irgendwann auseinanderlaeuft. + + 404 statt 403, wenn die Aufgabe nicht sichtbar ist: Wer sie nicht + sehen darf, soll auch nicht erfahren, dass es sie gibt. */ + +const NOTIZ_MAX = 2000; + +function aufgabeSichtbar(person, id) { + const { wo, werte } = sichtbar(person); + return db().prepare(`SELECT a.id FROM aufgaben a WHERE ${wo} AND a.id = ?`).get(...werte, id); +} + +aufgabenRouter.get("/workspace/api/aufgaben/:id/notizen", (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + if (!aufgabeSichtbar(req.person, id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + res.json({ + notizen: db().prepare(` + SELECT n.id, n.text, n.erstellt, n.person_id, p.name AS von, p.rolle AS rolle + FROM aufgaben_notizen n LEFT JOIN personen p ON p.id = n.person_id + WHERE n.aufgabe_id = ? ORDER BY n.id`).all(id), + ich: req.person.id, + darf_alles_loeschen: istLeitung(req.person), + }); + } catch (fehler) { + console.error("[workspace] Notizen lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +aufgabenRouter.post("/workspace/api/aufgaben/:id/notizen", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + if (!aufgabeSichtbar(req.person, id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const text = String(req.body?.text ?? "").trim().slice(0, NOTIZ_MAX); + if (text.length < 2) return res.status(400).json({ fehler: "Schreib erst etwas." }); + + const { lastInsertRowid } = db().prepare( + "INSERT INTO aufgaben_notizen (aufgabe_id, person_id, text, erstellt) VALUES (?,?,?,?)") + .run(id, req.person.id, text, jetzt()); + + protokolliere("aufgabe_notiz", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Aufgabe #${id}`.slice(0, 120), + }); + res.status(201).json({ id: Number(lastInsertRowid) }); + } catch (fehler) { + console.error("[workspace] Notiz schreiben:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +aufgabenRouter.delete("/workspace/api/notizen/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + const notiz = db().prepare( + "SELECT id, aufgabe_id, person_id FROM aufgaben_notizen WHERE id = ?").get(id); + if (!notiz) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!aufgabeSichtbar(req.person, notiz.aufgabe_id)) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + /* Die eigene Rueckmeldung darf jeder zuruecknehmen, fremde nur die + Leitung. Eine Rueckmeldung ist keine Abstimmung -- wer sich + vertippt hat, soll das nicht bei jemandem beantragen muessen. */ + if (notiz.person_id !== req.person.id && !istLeitung(req.person)) { + return res.status(403).json({ fehler: "Fremde Rückmeldungen löscht nur die Leitung." }); + } + db().prepare("DELETE FROM aufgaben_notizen WHERE id = ?").run(id); + protokolliere("aufgabe_notiz_geloescht", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} zu Aufgabe #${notiz.aufgabe_id}`.slice(0, 120), + }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Notiz löschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + aufgabenRouter.post("/workspace/api/aufgaben", gleicheHerkunft, (req, res) => { try { const { aus, fehler } = pruefeFelder(req.body || {}, { neu: true }); diff --git a/server/workspace.js b/server/workspace.js index 91cd6870..53fc759f 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -259,6 +259,23 @@ export function db() { CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status); CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id); + /* Rueckmeldungen zu einer Aufgabe (Konzept: "Aufgaben & Feedback"). + Ohne sie endet jede Rueckfrage ausserhalb des Systems -- in + WhatsApp, und damit ausserhalb dessen, was spaeter noch + nachvollziehbar ist. + Wer die Aufgabe sehen darf, darf auch die Rueckmeldungen sehen + und schreiben. Eine eigene Sichtbarkeitsregel gibt es bewusst + NICHT: Zwei Regeln fuer dieselbe Sache laufen irgendwann + auseinander. */ + CREATE TABLE IF NOT EXISTS aufgaben_notizen ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + aufgabe_id INTEGER NOT NULL REFERENCES aufgaben(id) ON DELETE CASCADE, + person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + text TEXT NOT NULL, + erstellt TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_aufgaben_notizen ON aufgaben_notizen (aufgabe_id); + /* Creator-Profil (Onboarding aus dem Konzept). Eine Zeile je Creator, entsteht erst beim ersten Speichern. admin_notiz ist bewusst Teil dieser Tabelle, wird aber nur an diff --git a/workspace/assets/css/aufgaben.css b/workspace/assets/css/aufgaben.css index 1acee8a7..56925aa4 100644 --- a/workspace/assets/css/aufgaben.css +++ b/workspace/assets/css/aufgaben.css @@ -165,3 +165,70 @@ .karte__stift { margin-left: auto; } .karte__kopf { display: flex; align-items: flex-start; gap: 8px; } .karte__kopf .karte__titel { flex: 1 1 auto; } + +/* =================================================================== + Rückmeldungen an einer Aufgabe ("Aufgaben & Feedback", Konzept). + + Direkt an der Karte, nicht in einem Fenster: Man liest sie im + Zusammenhang mit der Aufgabe -- sonst fehlt beim Antworten die + Hälfte. + =================================================================== */ + +.karte__rueck { + display: inline-flex; align-items: center; gap: 7px; + margin-top: 10px; padding: 6px 11px; cursor: pointer; + background: transparent; + border: 1px solid var(--rand); border-radius: 999px; + color: var(--text-still); font: inherit; font-size: .76rem; + transition: color .16s ease, border-color .16s ease, background .16s ease; +} +.karte__rueck:hover { color: var(--text); border-color: rgba(255, 255, 255, .24); } +/* Wo schon etwas besprochen wurde, sieht man es ohne Aufklappen. */ +.karte__rueck[data-hat="ja"] { + color: var(--akzent); + border-color: rgba(63, 189, 245, .38); + background: rgba(63, 189, 245, .08); +} +.karte__rueck[data-offen="ja"] { border-color: rgba(63, 189, 245, .6); color: #fff; } +.karte__blase { + width: 14px; height: 14px; flex: none; + fill: none; stroke: currentColor; stroke-width: 1.7; + stroke-linecap: round; stroke-linejoin: round; +} + +.rueck { + margin-top: 11px; padding-top: 11px; + border-top: 1px solid var(--rand); +} +.rueck__liste { display: grid; gap: 8px; margin-bottom: 11px; } +.rueck__eintrag { + padding: 9px 12px; + background: rgba(255, 255, 255, .035); + border: 1px solid var(--rand); border-radius: var(--radius-klein); +} +.rueck__kopf { display: flex; align-items: baseline; gap: 8px; margin: 0 0 3px; } +.rueck__wer { font-size: .79rem; font-weight: 650; color: var(--text); } +.rueck__wann { font-size: .72rem; color: var(--text-still); } +.rueck__weg { + margin-left: auto; padding: 0 5px; cursor: pointer; line-height: 1; + background: none; border: 0; color: var(--text-still); font-size: 1rem; +} +.rueck__weg:hover { color: #ffb4b9; } +.rueck__text { + margin: 0; font-size: .85rem; color: var(--text-leise); + white-space: pre-wrap; line-height: 1.5; +} + +.rueck__form { display: grid; gap: 8px; justify-items: start; } +.rueck__feld { + width: 100%; padding: 9px 12px; + background: rgba(255, 255, 255, .045); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; font-size: .86rem; resize: vertical; +} +.rueck__feld:focus { + outline: none; border-color: rgba(63, 189, 245, .65); + box-shadow: 0 0 0 3px rgba(63, 189, 245, .15); +} + +@media (prefers-reduced-motion: reduce) { .karte__rueck { transition: none; } } diff --git a/workspace/assets/js/aufgaben.js b/workspace/assets/js/aufgaben.js index e2adca2d..383ec698 100644 --- a/workspace/assets/js/aufgaben.js +++ b/workspace/assets/js/aufgaben.js @@ -49,6 +49,93 @@ 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; @@ -80,6 +167,23 @@ 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 = ''; + 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; } diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index d8248cf6..47b26a84 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -134,8 +134,8 @@ - - - + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 10e3a869..099ceea6 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -98,8 +98,8 @@ - - - + + + diff --git a/workspace/calls.html b/workspace/calls.html index 0517fe41..4ff12cdd 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -49,9 +49,9 @@ - - - - + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index 2adc1b79..10cdc8ba 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -88,8 +88,8 @@ - - - + + + diff --git a/workspace/index.html b/workspace/index.html index 6d2d4d8d..587c9188 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -25,7 +25,7 @@ type="image/webp" media="(max-width: 899px)" /> - + @@ -143,6 +143,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index 9972bf9c..443e6aa6 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -94,8 +94,8 @@ - - - + + + diff --git a/workspace/personen.html b/workspace/personen.html index aa9917c4..ef399543 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -93,8 +93,8 @@ - - - + + + diff --git a/workspace/profil.html b/workspace/profil.html index bb339c2b..73942205 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -127,8 +127,8 @@ - - - + + + diff --git a/workspace/report.html b/workspace/report.html index eed66bcc..bc997822 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -86,9 +86,9 @@ - - - - + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 21b89b4d..a18d3a4b 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,8 +124,8 @@ - - - + + + diff --git a/workspace/start.html b/workspace/start.html index f5016bb2..b03e8d58 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -56,8 +56,8 @@

- - - + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 82292c80..d315305a 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -65,9 +65,9 @@ - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 5669d135..d6a9bf14 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -170,8 +170,8 @@ - - - + + +