diff --git a/server/index.js b/server/index.js index 849d2df0..f535fb81 100644 --- a/server/index.js +++ b/server/index.js @@ -23,6 +23,7 @@ import { bereicheRouter } from "./workspace-bereiche.js"; import { reportRouter } from "./workspace-reports.js"; import { scoutRouter } from "./workspace-scouts.js"; import { callRouter } from "./workspace-calls.js"; +import { hinweisRouter } from "./workspace-hinweise.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -86,6 +87,7 @@ app.use(bereicheRouter); app.use(reportRouter); app.use(scoutRouter); app.use(callRouter); +app.use(hinweisRouter); /* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen). SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert diff --git a/server/workspace-aufgaben.js b/server/workspace-aufgaben.js index 8a6f2e1a..8a0e4f86 100644 --- a/server/workspace-aufgaben.js +++ b/server/workspace-aufgaben.js @@ -51,7 +51,7 @@ aufgabenRouter.use("/workspace/api", angemeldet); /* Liefert WHERE-Bedingung und Werte, passend zur Rolle. An genau einer Stelle definiert, damit keine Abfrage sie versehentlich vergisst. */ -function sichtbar(person) { +export function sichtbar(person) { switch (person.rolle) { case "admin": return { wo: "1=1", werte: [] }; diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index f6e32f81..ce94a2bc 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -83,7 +83,7 @@ bereicheRouter.use("/workspace/api/bereich", angemeldet); /* Scouts haben mit der Creator-Betreuung nichts zu tun -- sie sehen hier nichts. Creator sehen ihren eigenen Bereich, das Management alles. */ -function sichtbar(person) { +export function sichtbar(person) { if (person.rolle === "admin") return { wo: "1=1", werte: [] }; if (person.rolle === "creator") return { wo: "e.creator_id = ?", werte: [person.id] }; /* Ein Scout sieht die Bereiche der Creator, die er betreut -- und ohne diff --git a/server/workspace-dateien.js b/server/workspace-dateien.js index 3b193018..17410819 100644 --- a/server/workspace-dateien.js +++ b/server/workspace-dateien.js @@ -67,7 +67,7 @@ dateienRouter.use("/workspace/api/dateien", angemeldet); /* Sichtbarkeit wie überall: an einer Stelle, damit keine Abfrage sie vergisst. */ -function sichtbar(person) { +export function sichtbar(person) { if (person.rolle === "admin") return { wo: "1=1", werte: [] }; if (person.rolle === "creator") { return { wo: "(d.creator_id = ? OR d.hochgeladen_von = ?)", werte: [person.id, person.id] }; diff --git a/server/workspace-hinweise.js b/server/workspace-hinweise.js new file mode 100644 index 00000000..af292b01 --- /dev/null +++ b/server/workspace-hinweise.js @@ -0,0 +1,199 @@ +/* ===================================================================== + workspace-hinweise.js — "Was ist dran?" (Konzept, Phase 4). + + Der erste Baustein der Automationen. Bis hierher musste man selbst + daran denken, in den richtigen Bereich zu schauen. Diese Datei dreht + das um: Das System sagt, was liegen bleibt. + + Drei Regeln, an die sich das hier haelt: + + 1. KEINE eigenen Daten. Ein Hinweis ist immer nur eine Sicht auf + etwas, das ohnehin schon existiert. Wird die Aufgabe erledigt, + verschwindet der Hinweis von selbst -- es gibt nichts zu quittieren + und nichts, was veralten kann. + + 2. JEDER HINWEIS FUEHRT IRGENDWOHIN. Ein Hinweis ohne Ziel ist nur + ein schlechtes Gewissen. Deshalb traegt jeder einen Link zu der + Stelle, an der man das Problem tatsaechlich loesen kann. + + 3. DIESELBE SICHTBARKEIT WIE UEBERALL. Die Regeln werden aus den + Fachmodulen importiert, nicht abgeschrieben. Ein Hinweis darf + niemals etwas verraten, das die zugehoerige Seite verbergen wuerde + -- sonst waere die Uebersicht ein Leck. + ===================================================================== */ + +import express from "express"; +import { db, sitzungLesen, betreuteIds } from "./workspace.js"; +import { sichtbar as sichtbarAufgaben } from "./workspace-aufgaben.js"; +import { sichtbar as sichtbarTermine } from "./workspace-kalender.js"; +import { sichtbar as sichtbarDateien } from "./workspace-dateien.js"; +import { sichtbar as sichtbarEintraege } from "./workspace-bereiche.js"; + +export const hinweisRouter = express.Router(); + +/* Reihenfolge = Dringlichkeit. "warnung" steht immer oben. */ +const RANG = { warnung: 0, offen: 1, ruhig: 2 }; + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +hinweisRouter.use("/workspace/api/hinweise", angemeldet); + +const p2 = (n) => String(n).padStart(2, "0"); +function heuteLokal() { + const d = new Date(); + return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`; +} +function jetztLokal() { + const d = new Date(); + return `${heuteLokal()}T${p2(d.getHours())}:${p2(d.getMinutes())}`; +} +function inTagen(n) { + const d = new Date(Date.now() + n * 86400_000); + return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`; +} + +/* Kleine Hilfe: eine Zahl abfragen, Fehler eines einzelnen Hinweises + duerfen nie die ganze Uebersicht kippen. */ +function zaehle(sql, werte) { + try { + return db().prepare(sql).get(...werte)?.n ?? 0; + } catch { + return 0; + } +} + +hinweisRouter.get("/workspace/api/hinweise", (req, res) => { + try { + const person = req.person; + const heute = heuteLokal(); + const jetzt = jetztLokal(); + const hinweise = []; + + const dazu = (art, stufe, text, ziel, anzahl) => { + if (!anzahl) return; + hinweise.push({ art, stufe, text, ziel, anzahl }); + }; + + /* ---------- Aufgaben ---------- */ + const a = sichtbarAufgaben(person); + if (a) { + dazu("aufgaben_ueberfaellig", "warnung", + "überfällig", "aufgaben.html", + zaehle(`SELECT COUNT(*) n FROM aufgaben a + WHERE ${a.wo} AND a.status <> 'erledigt' + AND a.frist IS NOT NULL AND a.frist < ?`, [...a.werte, heute])); + + dazu("aufgaben_heute", "offen", + "heute fällig", "aufgaben.html", + zaehle(`SELECT COUNT(*) n FROM aufgaben a + WHERE ${a.wo} AND a.status <> 'erledigt' AND a.frist = ?`, + [...a.werte, heute])); + + /* Aufgaben, die im Review haengen. Sie warten auf jemanden -- + das ist genau die Sorte Stillstand, die niemandem auffaellt. */ + dazu("aufgaben_review", "offen", + "wartet auf Freigabe", "aufgaben.html", + zaehle(`SELECT COUNT(*) n FROM aufgaben a + WHERE ${a.wo} AND a.status = 'review'`, a.werte)); + } + + /* ---------- Termine und Calls ---------- */ + const t = sichtbarTermine(person); + if (t) { + dazu("termine_heute", "offen", + "heute im Kalender", "kalender.html", + zaehle(`SELECT COUNT(*) n FROM termine t + WHERE ${t.wo} AND t.beginn >= ? AND t.beginn < ?`, + [...t.werte, heute + "T00:00", inTagen(1) + "T00:00"])); + + /* Vergangene Gespraeche ohne Protokoll -- der einzige Zustand im + Call-Bereich, der aktiv etwas verlangt. */ + dazu("protokoll_fehlt", "warnung", + "Gespräch ohne Protokoll", "calls.html", + zaehle(`SELECT COUNT(*) n FROM termine t + LEFT JOIN protokolle pr ON pr.termin_id = t.id + WHERE ${t.wo} AND t.art IN ('call','review') + AND t.beginn <= ? AND pr.id IS NULL`, [...t.werte, jetzt])); + } + + /* ---------- Dateien ---------- */ + const d = sichtbarDateien(person); + if (d) { + dazu("dateien_review", person.rolle === "admin" ? "warnung" : "offen", + "Datei im Review", "dateien.html", + zaehle(`SELECT COUNT(*) n FROM dateien d + WHERE ${d.wo} AND d.status = 'review'`, d.werte)); + } + + /* ---------- Betreuungsbereiche ---------- */ + const e = sichtbarEintraege(person); + if (e) { + dazu("bereiche_dringend", "warnung", + "dringender Punkt in den Bereichen", "bereich.html?b=live", + zaehle(`SELECT COUNT(*) n FROM eintraege e + WHERE ${e.wo} AND e.status = 'offen' AND e.dringlichkeit = 'hoch'`, + e.werte)); + } + + /* ---------- Scout-Pipeline ---------- */ + if (person.rolle === "admin" || person.rolle === "scout") { + const nur = person.rolle === "admin" ? "1=1" : "l.scout_id = ?"; + const werte = person.rolle === "admin" ? [] : [person.id]; + dazu("followup_faellig", "warnung", + "Follow-up fällig", "scouting.html", + zaehle(`SELECT COUNT(*) n FROM leads l + WHERE ${nur} AND l.status NOT IN ('uebergeben','abgelehnt') + AND l.naechster_followup IS NOT NULL AND l.naechster_followup <= ?`, + [...werte, heute])); + + /* Uebergebene Leads, aus denen nie ein Creator wurde. Die + Uebergabe ist sonst eine Sackgasse, die niemand bemerkt. */ + dazu("uebergabe_offen", "offen", + "übergeben, aber noch kein Creator angelegt", "scouting.html", + zaehle(`SELECT COUNT(*) n FROM leads l + WHERE ${nur} AND l.status = 'uebergeben' AND l.creator_id IS NULL`, + werte)); + } + + /* ---------- Nur Management ---------- + Review-Termine und fehlende Zustaendigkeit sind Steuerungswissen. + Sie erscheinen deshalb weder bei Creator noch bei Scouts -- so wie + die zugehoerigen Felder im Profil auch. */ + if (person.rolle === "admin") { + dazu("review_faellig", "warnung", + "Review-Termin überfällig", "profil.html", + zaehle(`SELECT COUNT(*) n FROM profile f + JOIN personen p ON p.id = f.person_id AND p.aktiv = 1 + WHERE f.naechster_review IS NOT NULL AND f.naechster_review < ?`, [heute])); + + dazu("review_bald", "offen", + "Review steht in den nächsten 7 Tagen an", "profil.html", + zaehle(`SELECT COUNT(*) n FROM profile f + JOIN personen p ON p.id = f.person_id AND p.aktiv = 1 + WHERE f.naechster_review BETWEEN ? AND ?`, [heute, inTagen(7)])); + + dazu("ohne_betreuung", "ruhig", + "Creator ohne zuständige Person", "personen.html", + zaehle(`SELECT COUNT(*) n FROM personen p + LEFT JOIN betreuung b ON b.creator_id = p.id + WHERE p.rolle = 'creator' AND p.aktiv = 1 AND b.creator_id IS NULL`, [])); + + dazu("ohne_profil", "ruhig", + "Creator ohne ausgefülltes Profil", "profil.html", + zaehle(`SELECT COUNT(*) n FROM personen p + LEFT JOIN profile f ON f.person_id = p.id + WHERE p.rolle = 'creator' AND p.aktiv = 1 AND f.person_id IS NULL`, [])); + } + + hinweise.sort((x, y) => (RANG[x.stufe] - RANG[y.stufe]) || (y.anzahl - x.anzahl)); + res.json({ hinweise, stand: new Date().toISOString() }); + } catch (fehler) { + console.error("[workspace] Hinweise:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 0a01e081..f3df3af9 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -230,3 +230,49 @@ body.start { background: rgba(255, 255, 255, .04); } @media (prefers-reduced-motion: reduce) { .knopf-still { transition: none; } } + +/* ---------- Was ist dran ------------------------------------------------ + Steht vor den Zahlen. Zahlen sagen, WIE VIEL anliegt -- diese Liste + sagt, WAS zu tun ist. Nur drei Stufen, und nur die oberste ist farbig: + Waere alles hervorgehoben, waere nichts hervorgehoben. */ +.dran { margin-bottom: 30px; } +.dran__liste { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; } + +.dran__punkt { + position: relative; + border: 1px solid var(--rand); + border-radius: var(--radius-klein); + background: rgba(255, 255, 255, .028); + transition: border-color .18s ease, background .18s ease; +} +.dran__punkt::before { + content: ""; position: absolute; left: 0; top: 9px; bottom: 9px; width: 2px; + border-radius: 0 2px 2px 0; + background: var(--rand); +} +.dran__punkt[data-stufe="warnung"] { + border-color: rgba(255, 154, 162, .3); + background: rgba(255, 154, 162, .055); +} +.dran__punkt[data-stufe="warnung"]::before { background: var(--warn); } +.dran__punkt[data-stufe="offen"]::before { background: var(--akzent); opacity: .7; } +.dran__punkt[data-stufe="ruhig"] { opacity: .78; } +.dran__punkt:hover { border-color: rgba(255, 255, 255, .2); background: rgba(255, 255, 255, .05); } +.dran__punkt[data-stufe="ruhig"]:hover { opacity: 1; } + +.dran__link { + display: flex; align-items: center; justify-content: space-between; gap: 14px; + padding: 12px 15px 12px 18px; + color: var(--text); text-decoration: none; font-size: .9rem; +} +.dran__punkt[data-stufe="warnung"] .dran__text { font-weight: 600; } +.dran__pfeil { + color: var(--text-still); font-size: .95rem; + transition: transform .18s ease, color .18s ease; +} +.dran__link:hover .dran__pfeil { color: var(--akzent); transform: translateX(3px); } + +@media (prefers-reduced-motion: reduce) { + .dran__punkt, .dran__pfeil { transition: none; } + .dran__link:hover .dran__pfeil { transform: none; } +} diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index c07d60d5..9d81cfa4 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -80,9 +80,69 @@ liste.append(li); } + await hinweiseLaden(); await zahlenLaden(); } + /* Was ist dran? Erster Baustein der Automationen aus dem Konzept. + Fuehrt bewusst keine eigenen Daten -- jeder Hinweis ist nur eine + Sicht auf etwas, das schon da ist, und verschwindet von selbst, + sobald die Sache erledigt ist. Nichts zu quittieren. */ + const EINZAHL = { + aufgaben_ueberfaellig: (n) => `${n} ${n === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} überfällig`, + aufgaben_heute: (n) => `${n} ${n === 1 ? 'Aufgabe ist' : 'Aufgaben sind'} heute fällig`, + aufgaben_review: (n) => `${n} ${n === 1 ? 'Aufgabe wartet' : 'Aufgaben warten'} auf Freigabe`, + termine_heute: (n) => `${n} ${n === 1 ? 'Termin steht' : 'Termine stehen'} heute an`, + protokoll_fehlt: (n) => `${n} ${n === 1 ? 'Gespräch hat' : 'Gespräche haben'} noch kein Protokoll`, + dateien_review: (n) => `${n} ${n === 1 ? 'Datei liegt' : 'Dateien liegen'} im Review`, + bereiche_dringend: (n) => `${n} ${n === 1 ? 'dringender Punkt' : 'dringende Punkte'} in den Bereichen`, + followup_faellig: (n) => `${n} Follow-up${n === 1 ? '' : 's'} ${n === 1 ? 'ist' : 'sind'} fällig`, + uebergabe_offen: (n) => `${n} übergebene${n === 1 ? 'r Lead wartet' : ' Leads warten'} auf das Onboarding`, + review_faellig: (n) => `${n} Review-Termin${n === 1 ? ' ist' : 'e sind'} überfällig`, + review_bald: (n) => `${n} Review${n === 1 ? ' steht' : 's stehen'} in den nächsten 7 Tagen an`, + ohne_betreuung: (n) => `${n} Creator ${n === 1 ? 'hat' : 'haben'} noch keine zuständige Person`, + ohne_profil: (n) => `${n} Creator-Profil${n === 1 ? ' ist' : 'e sind'} noch leer`, + }; + + async function hinweiseLaden() { + let daten; + try { + const antwort = await fetch('/workspace/api/hinweise', { credentials: 'same-origin' }); + if (!antwort.ok) return; + daten = await antwort.json(); + } catch { return; } + + const liste = $('dran'); + liste.textContent = ''; + if (!daten.hinweise.length) { $('dran-block').hidden = true; return; } + + for (const h of daten.hinweise) { + const li = document.createElement('li'); + li.className = 'dran__punkt'; + li.dataset.stufe = h.stufe; + + /* Jeder Hinweis fuehrt irgendwohin -- ein Hinweis ohne Ziel ist + nur ein schlechtes Gewissen. */ + const a = document.createElement('a'); + a.className = 'dran__link'; + a.href = h.ziel; + + const text = document.createElement('span'); + text.className = 'dran__text'; + text.textContent = (EINZAHL[h.art] || ((n) => `${n} × ${h.text}`))(h.anzahl); + + const pfeil = document.createElement('span'); + pfeil.className = 'dran__pfeil'; + pfeil.setAttribute('aria-hidden', 'true'); + pfeil.textContent = '→'; + + a.append(text, pfeil); + li.append(a); + liste.append(li); + } + $('dran-block').hidden = false; + } + /* Die Zahlen kommen aus derselben Quelle wie das Brett und sind bereits serverseitig auf die eigene Rolle eingegrenzt. */ const ZAHLEN = [ diff --git a/workspace/start.html b/workspace/start.html index d560b08e..e34a7f6b 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -28,6 +28,14 @@

…

+ + +

Deine Aufgaben

@@ -42,8 +50,9 @@

- Anmeldung, Sitzung und Protokoll funktionieren bereits. - Die Bereiche werden nach dem Phasenplan gebaut. + „Was ist dran" führt keine eigene Liste, sondern zeigt, was in den Bereichen + schon liegt – ein Punkt verschwindet von selbst, sobald die Sache erledigt ist. + Angezeigt wird nur, was du ohnehin sehen darfst.