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 = '