diff --git a/server/workspace-ki.js b/server/workspace-ki.js index 9fe889dd..cdc565b0 100644 --- a/server/workspace-ki.js +++ b/server/workspace-ki.js @@ -27,7 +27,9 @@ ===================================================================== */ import express from "express"; -import { sitzungLesen, protokolliere, echteIp } from "./workspace.js"; +import { + sitzungLesen, protokolliere, echteIp, einstellung, einstellungSetzen, istLeitung, +} from "./workspace.js"; export const kiRouter = express.Router(); @@ -38,6 +40,11 @@ const TEXT_MAX = 4000; /* Nur eine Anfrage zur Zeit -- siehe Regel 3. */ let laeuft = false; +/* Der Schalter aus der Automationen-Seite. Steht bewusst hier oben, vor + der ersten Verwendung -- als const weiter unten waere es zwar zur + Laufzeit gutgegangen, aber beim Lesen eine Stolperfalle. */ +const ANGESCHALTET = () => einstellung("ki_an", "1") !== "0"; + /* Einfache Bremse je Person: fuenf Anfragen in zehn Minuten. Die KI ist billig im Geld, aber teuer in Rechenzeit, die der Website gehoert. */ const VERBRAUCH = new Map(); @@ -113,6 +120,11 @@ async function frage(anweisung, text, maxWorte, beispiele = []) { /* Gemeinsamer Rahmen: Bremse, Sperre, Fehler abfangen. So steht die Absicherung einmal da und nicht in jedem Endpunkt neu. */ async function mitSchutz(req, res, aufgabe, was) { + /* Der Schalter wird HIER geprueft, nicht nur beim Anzeigen der + Knoepfe. Eine Oberflaeche, die etwas versteckt, ist keine Sperre. */ + if (!ANGESCHALTET()) { + return res.status(403).json({ fehler: "Die KI-Vorschläge sind abgeschaltet." }); + } if (zuVielGefragt(req.person.id)) { return res.status(429).json({ fehler: `Höchstens ${LIMIT} Vorschläge in ${FENSTER_MS / 60000} Minuten – die KI teilt sich den Server mit der Website.`, @@ -150,18 +162,37 @@ async function mitSchutz(req, res, aufgabe, was) { als gar kein Knopf. */ kiRouter.get("/workspace/api/ki/status", async (req, res) => { + /* Abgeschaltet heisst abgeschaltet -- dann wird der Dienst gar nicht + erst gefragt, und die Knoepfe erscheinen nirgends. */ + if (!ANGESCHALTET()) { + return res.json({ da: false, angeschaltet: false, erreichbar: null, beschaeftigt: false }); + } const abbruch = new AbortController(); const uhr = setTimeout(() => abbruch.abort(), 2500); try { const a = await fetch(KI_URL + "/health", { signal: abbruch.signal }); - res.json({ da: a.ok, beschaeftigt: laeuft }); + res.json({ da: a.ok, angeschaltet: true, erreichbar: a.ok, beschaeftigt: laeuft }); } catch { - res.json({ da: false, beschaeftigt: false }); + res.json({ da: false, angeschaltet: true, erreichbar: false, beschaeftigt: false }); } finally { clearTimeout(uhr); } }); +/* Ein- und ausschalten. Nur die Leitung -- es ist eine Entscheidung + ueber Rechenzeit, die der Website gehoert. */ +kiRouter.put("/workspace/api/ki/schalter", gleicheHerkunft, express.json(), (req, res) => { + try { + if (!istLeitung(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + const an = req.body?.an === true || req.body?.an === "1"; + einstellungSetzen("ki_an", an ? "1" : "0", { ...req.person, ip: echteIp(req) }); + res.json({ ok: true, angeschaltet: an }); + } catch (fehler) { + console.error("[workspace] KI-Schalter:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + /* ---------- Aus einem Gespraech To-dos vorschlagen ----------------------- */ kiRouter.post("/workspace/api/ki/todos", gleicheHerkunft, express.json(), (req, res) => { diff --git a/server/workspace.js b/server/workspace.js index 53fc759f..bbe19ba2 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -259,6 +259,18 @@ 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); + /* Einstellungen, die sich zur Laufzeit aendern lassen. Bisher nur + eine: ob die KI-Vorschlaege angeboten werden. Als Tabelle statt + als Datei, damit sie dieselbe Sicherung bekommt wie alles + andere -- eine Einstellung, die beim Wiederherstellen fehlt, + faellt erst auf, wenn etwas nicht mehr geht. */ + CREATE TABLE IF NOT EXISTS einstellungen ( + schluessel TEXT PRIMARY KEY, + wert TEXT NOT NULL, + geaendert TEXT, + von INTEGER REFERENCES personen(id) ON DELETE SET NULL + ); + /* Rueckmeldungen zu einer Aufgabe (Konzept: "Aufgaben & Feedback"). Ohne sie endet jede Rueckfrage ausserhalb des Systems -- in WhatsApp, und damit ausserhalb dessen, was spaeter noch @@ -635,6 +647,7 @@ const GESCHUETZT = { "/workspace/calls.html": null, "/workspace/startcheck.html": null, "/workspace/wissen.html": null, + "/workspace/automation.html": ["admin", "manager"], }; workspaceRouter.use((req, res, next) => { @@ -807,6 +820,31 @@ export function betreuungSetzen(creatorId, betreuerId, akteur = null) { }); } +/* ---------- Einstellungen ------------------------------------------------- + Bewusst mit Vorgabewert und ohne Fehlerfall: Faellt die Datenbank aus, + soll eine fehlende Einstellung nicht die Seite mitreissen. */ + +export function einstellung(schluessel, vorgabe = null) { + try { + return db().prepare("SELECT wert FROM einstellungen WHERE schluessel = ?") + .get(schluessel)?.wert ?? vorgabe; + } catch { + return vorgabe; + } +} + +export function einstellungSetzen(schluessel, wert, akteur = null) { + db().prepare(` + INSERT INTO einstellungen (schluessel, wert, geaendert, von) VALUES (?,?,?,?) + ON CONFLICT(schluessel) DO UPDATE SET + wert = excluded.wert, geaendert = excluded.geaendert, von = excluded.von`).run( + schluessel, String(wert), new Date().toISOString(), akteur?.id ?? null); + protokolliere("einstellung_geaendert", { + personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, ip: akteur?.ip ?? null, + detail: `${schluessel} = ${wert}`.slice(0, 120), + }); +} + export function personAnlegen(name, rolle, akteur = null) { if (!ROLLEN.has(rolle)) throw new Error(`Unbekannte Rolle: ${rolle}`); const code = codeErzeugen(); diff --git a/workspace/assets/css/automation.css b/workspace/assets/css/automation.css new file mode 100644 index 00000000..8aa164e6 --- /dev/null +++ b/workspace/assets/css/automation.css @@ -0,0 +1,123 @@ +/* =================================================================== + Automationen. + + Ein Zustandsbild, keine Bedienoberflaeche: Der einzige Schalter ist + der fuer die KI, weil sie als einzige Rechenzeit kostet, die der + Website gehoert. Alles andere laeuft und wird hier nur gezeigt. + =================================================================== */ + +.block { margin-bottom: 34px; } +.block__kopf { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.block__titel { + margin: 0; font-size: .78rem; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; color: var(--akzent); +} +.block__zahl { + min-width: 22px; padding: 1px 8px; text-align: center; + font-size: .74rem; font-weight: 700; color: var(--text-still); + background: rgba(255, 255, 255, .06); border-radius: 999px; +} +.block__frage { margin: 0 0 14px; font-size: .85rem; color: var(--text-still); max-width: 76ch; } + +/* ---------- KI ------------------------------------------------------------ */ + +.ki-kasten { + margin-bottom: 34px; padding: 20px 22px; + border: 1px solid var(--rand); border-radius: var(--radius); + background: rgba(255, 255, 255, .028); +} +.ki-kasten[data-zustand="laeuft"] { + border-color: rgba(138, 118, 255, .34); + background: rgba(138, 118, 255, .05); +} +.ki-kasten[data-zustand="weg"] { + border-color: rgba(255, 154, 162, .3); + background: rgba(255, 154, 162, .045); +} +.ki-kasten__kopf { + display: flex; gap: 20px; align-items: flex-start; + justify-content: space-between; flex-wrap: wrap; +} +.ki-kasten__titel { margin: 2px 0 6px; font-size: 1.1rem; font-weight: 700; } +.ki-kasten__text { margin: 0; font-size: .88rem; color: var(--text-leise); max-width: 62ch; } + +.ki-kasten__daten { + display: grid; gap: 7px; margin: 18px 0 0; padding: 16px 0 0; list-style: none; + border-top: 1px solid var(--rand); + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} +.ki-kasten__daten li { display: flex; gap: 10px; font-size: .83rem; color: var(--text-leise); } +.ki-kasten__schild { + flex: none; min-width: 74px; + font-size: .7rem; font-weight: 700; letter-spacing: .07em; + text-transform: uppercase; color: var(--text-still); padding-top: 2px; +} + +/* ---------- Hinweise ------------------------------------------------------- */ + +.hinweise { display: grid; gap: 7px; } +.hinweis { + position: relative; display: flex; align-items: center; gap: 12px; + padding: 11px 15px 11px 18px; + text-decoration: none; color: var(--text); font-size: .89rem; + border: 1px solid var(--rand); border-radius: var(--radius-klein); + background: rgba(255, 255, 255, .028); + transition: border-color .18s ease, background .18s ease; +} +.hinweis::before { + content: ""; position: absolute; left: 0; top: 9px; bottom: 9px; width: 2px; + border-radius: 0 2px 2px 0; background: var(--rand); +} +.hinweis[data-stufe="warnung"] { + border-color: rgba(255, 154, 162, .3); background: rgba(255, 154, 162, .05); +} +.hinweis[data-stufe="warnung"]::before { background: var(--warn); } +.hinweis[data-stufe="offen"]::before { background: var(--akzent); opacity: .7; } +.hinweis[data-stufe="ruhig"] { opacity: .8; } +.hinweis:hover { border-color: rgba(255, 255, 255, .2); background: rgba(255, 255, 255, .05); } +.hinweis__zahl { + flex: none; min-width: 30px; text-align: center; + font-size: 1.05rem; font-weight: 700; +} +.hinweis[data-stufe="warnung"] .hinweis__zahl { color: var(--warn); } +.hinweis__text { flex: 1; color: var(--text-leise); } +.hinweis__pfeil { color: var(--text-still); } +.hinweis:hover .hinweis__pfeil { color: var(--akzent); } + +/* ---------- Regeln --------------------------------------------------------- */ + +.regeln { + display: grid; gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} +.regel { + padding: 15px 17px; + border: 1px solid var(--rand); border-radius: var(--radius-klein); + background: rgba(255, 255, 255, .025); +} +.regel__name { margin: 0 0 5px; font-size: .93rem; font-weight: 650; } +.regel__text { margin: 0; font-size: .83rem; color: var(--text-still); line-height: 1.55; } + +/* ---------- Verlauf -------------------------------------------------------- */ + +.verlauf { display: grid; gap: 2px; } +.verlauf__zeile { + display: grid; gap: 12px; align-items: baseline; + grid-template-columns: 96px minmax(0, 1fr) minmax(0, 1.2fr); + padding: 8px 12px; border-radius: var(--radius-klein); + font-size: .82rem; +} +.verlauf__zeile:nth-child(odd) { background: rgba(255, 255, 255, .022); } +.verlauf__zeit { color: var(--text-still); font-variant-numeric: tabular-nums; } +.verlauf__was { color: var(--text); } +.verlauf__wer { color: var(--text-still); overflow: hidden; text-overflow: ellipsis; } + +.leer-hinweis { + padding: 26px; text-align: center; color: var(--text-still); font-size: .9rem; + border: 1px dashed var(--rand); border-radius: var(--radius); +} + +@media (max-width: 700px) { + .verlauf__zeile { grid-template-columns: 1fr; gap: 2px; } +} +@media (prefers-reduced-motion: reduce) { .hinweis { transition: none; } } diff --git a/workspace/assets/css/gate.css b/workspace/assets/css/gate.css index f20e9e21..d76503dc 100644 --- a/workspace/assets/css/gate.css +++ b/workspace/assets/css/gate.css @@ -784,3 +784,54 @@ body.suche-offen { overflow: hidden; } @media (prefers-reduced-motion: reduce) { .wahl2__knopf, .wahl2__pfeil { transition: none; } } + +/* =================================================================== + Schalter (31.08.2026). + + Lag zuerst in wissen.css -- und fehlte prompt auf der Automationen- + Seite, wo er als nacktes Kaestchen erschien. Genau derselbe Fehler + wie damals bei .knopf-still: Ein gemeinsamer Baustein in einer + Seiten-Datei fehlt irgendwann dort, wo die Datei nicht geladen wird. + =================================================================== */ +/* ---------- Schalter „besonders wichtig“ --------------------------------- + Ein Kästchen von 17 Pixeln ist auf dem Handy kaum zu treffen und sagt + nicht, was passiert. Der Schalter ist groß, zeigt seinen Zustand und + erklärt sich in einer Zeile. */ + +.schalter { + display: flex; align-items: flex-start; gap: 14px; + margin-top: 18px; padding: 14px 16px; cursor: pointer; + border: 1px solid var(--rand); border-radius: var(--radius); + background: rgba(255, 255, 255, .022); + transition: border-color .18s ease, background .18s ease; +} +.schalter:hover { border-color: rgba(255, 255, 255, .2); } +.schalter input { position: absolute; opacity: 0; width: 0; height: 0; } +.schalter__gleis { + flex: none; position: relative; width: 42px; height: 24px; margin-top: 1px; + border-radius: 999px; + background: rgba(255, 255, 255, .1); + border: 1px solid var(--rand); + transition: background .2s ease, border-color .2s ease; +} +.schalter__punkt { + position: absolute; left: 3px; top: 3px; width: 16px; height: 16px; + border-radius: 50%; background: var(--text-still); + transition: transform .2s ease, background .2s ease; +} +.schalter input:checked + .schalter__gleis { + background: rgba(232, 192, 125, .3); + border-color: rgba(232, 192, 125, .6); +} +.schalter input:checked + .schalter__gleis .schalter__punkt { + transform: translateX(18px); background: #e8c07d; +} +.schalter input:focus-visible + .schalter__gleis { + box-shadow: 0 0 0 3px rgba(232, 192, 125, .28); +} +.schalter:has(input:checked) { + border-color: rgba(232, 192, 125, .35); + background: rgba(232, 192, 125, .05); +} +.schalter__text { display: grid; gap: 2px; font-size: .88rem; } +.schalter__text .leise { font-size: .79rem; } diff --git a/workspace/assets/css/wissen.css b/workspace/assets/css/wissen.css index 8a281c167..3a2486d7 100644 --- a/workspace/assets/css/wissen.css +++ b/workspace/assets/css/wissen.css @@ -242,48 +242,6 @@ .pdf-ablage__text strong { color: var(--akzent); } .pdf-ablage__klein { margin: 0; font-size: .77rem; color: var(--text-still); } -/* ---------- Schalter „besonders wichtig“ --------------------------------- - Ein Kästchen von 17 Pixeln ist auf dem Handy kaum zu treffen und sagt - nicht, was passiert. Der Schalter ist groß, zeigt seinen Zustand und - erklärt sich in einer Zeile. */ - -.schalter { - display: flex; align-items: flex-start; gap: 14px; - margin-top: 18px; padding: 14px 16px; cursor: pointer; - border: 1px solid var(--rand); border-radius: var(--radius); - background: rgba(255, 255, 255, .022); - transition: border-color .18s ease, background .18s ease; -} -.schalter:hover { border-color: rgba(255, 255, 255, .2); } -.schalter input { position: absolute; opacity: 0; width: 0; height: 0; } -.schalter__gleis { - flex: none; position: relative; width: 42px; height: 24px; margin-top: 1px; - border-radius: 999px; - background: rgba(255, 255, 255, .1); - border: 1px solid var(--rand); - transition: background .2s ease, border-color .2s ease; -} -.schalter__punkt { - position: absolute; left: 3px; top: 3px; width: 16px; height: 16px; - border-radius: 50%; background: var(--text-still); - transition: transform .2s ease, background .2s ease; -} -.schalter input:checked + .schalter__gleis { - background: rgba(232, 192, 125, .3); - border-color: rgba(232, 192, 125, .6); -} -.schalter input:checked + .schalter__gleis .schalter__punkt { - transform: translateX(18px); background: #e8c07d; -} -.schalter input:focus-visible + .schalter__gleis { - box-shadow: 0 0 0 3px rgba(232, 192, 125, .28); -} -.schalter:has(input:checked) { - border-color: rgba(232, 192, 125, .35); - background: rgba(232, 192, 125, .05); -} -.schalter__text { display: grid; gap: 2px; font-size: .88rem; } -.schalter__text .leise { font-size: .79rem; } .tag-vorschlag { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 8px; } .tag-knopf { diff --git a/workspace/assets/js/automation.js b/workspace/assets/js/automation.js new file mode 100644 index 00000000..32646661 --- /dev/null +++ b/workspace/assets/js/automation.js @@ -0,0 +1,206 @@ +/* =================================================================== + Automationen. + + Die Kachel trug lange "Phase 4", obwohl die Automationen laengst + liefen -- sie waren nur verteilt und hatten keinen Ort. Diese Seite + ist dieser Ort. + + Sie fuehrt bewusst NICHTS Eigenes: Was hier steht, kommt aus den + Bereichen selbst. Eine Seite, die Automationen doppelt abbildet, + waere die naechste Stelle, die irgendwann etwas anderes behauptet als + die Wirklichkeit. + =================================================================== */ +(() => { + 'use strict'; + + const $ = (id) => document.getElementById(id); + 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 = (t) => { $('fehler').textContent = t || ''; }; + + async function hole(pfad, optionen = {}) { + const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + return a; + } + + /* Was dauerhaft laeuft. Steht hier als Text, weil es Verhalten ist und + keine Daten -- aber jede Zeile beschreibt etwas, das tatsaechlich im + Code steht, nicht etwas Gewuenschtes. */ + const REGELN = [ + { name: 'Hinweise auf der Übersicht', + text: 'Überfälliges, heute Fälliges, Gespräche ohne Protokoll, fällige Follow-ups und dringende Punkte werden bei jedem Aufruf neu ermittelt. Sie führen keine eigene Liste – ein Hinweis verschwindet von selbst, sobald die Sache erledigt ist.' }, + { name: '„Neu" in der Bibliothek', + text: 'Eine frisch eingestellte Anleitung steht 48 Stunden unter „Neu & wichtig“ und ist danach nur noch in ihrer Kategorie zu finden. Angepinntes bleibt, bis es jemand abschaltet.' }, + { name: 'Zuständigkeit bei der Übergabe', + text: 'Wird aus einem übergebenen Lead ein Creator angelegt, betreut ihn automatisch der Scout, der ihn gefunden hat. Die Angaben aus der Pipeline wandern dabei ins Creator-Profil.' }, + { name: 'Aufgaben aus Gesprächen', + text: 'To-dos aus einem Protokoll, Entscheidungen aus einem Review und Befunde aus dem Start-Check werden zu echten Aufgaben im Brett – in einem Zug, nicht als Text, den man später abtippt.' }, + { name: 'Sitzungen laufen ab', + text: 'Eine Anmeldung gilt zwölf Stunden. Nach einem neuen Zugangscode oder einer Sperre enden alle offenen Sitzungen der Person sofort.' }, + { name: 'Schutz vor Rateversuchen', + text: 'Acht Fehlversuche je Anschluss in zehn Minuten, danach ist zu. Gezählt wird die echte Adresse, nicht die von Cloudflare.' }, + { name: 'Sicherung vor Umbauten', + text: 'Bevor die Datenbank umgestellt wird, schreibt der Server eine vollständige Sicherung. Ohne Sicherung wird nicht umgestellt.' }, + ]; + + const ART = { + ki_gefragt: 'KI um einen Vorschlag gebeten', + einstellung_geaendert: 'Einstellung geändert', + betreuung_gesetzt: 'Zuständigkeit gesetzt', + onboarding_gestartet: 'Creator aus einem Lead angelegt', + protokoll_geschrieben: 'Gesprächsprotokoll festgehalten', + startcheck_aufgabe: 'Aufgabe aus dem Start-Check', + review_entscheidung: 'Aufgabe aus dem Review', + anmeldung_gesperrt: 'Anmeldung wegen zu vieler Versuche gesperrt', + }; + + 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())}`; + }; + + /* ---------- KI ---------------------------------------------------------- */ + + async function kiZustand() { + let d; + try { d = await (await hole('/workspace/api/ki/status')).json(); } catch { return; } + + $('ki-schalter-block').hidden = false; + $('ki-schalter').checked = d.angeschaltet !== false; + $('ki-schalter-text').textContent = d.angeschaltet !== false ? 'Angeschaltet' : 'Abgeschaltet'; + $('ki-kasten').dataset.zustand = d.da ? 'laeuft' : (d.angeschaltet === false ? 'aus' : 'weg'); + + $('ki-text').textContent = d.angeschaltet === false + ? 'Abgeschaltet. Die Vorschlags-Knöpfe erscheinen nirgends, und der Dienst wird nicht gefragt.' + : d.da + ? 'Läuft. Die Vorschlags-Knöpfe erscheinen in Calls, Start-Check und Report.' + : 'Angeschaltet, aber gerade nicht erreichbar. Alles andere funktioniert normal weiter.'; + + const daten = $('ki-daten'); + daten.textContent = ''; + for (const [name, wert] of [ + ['Modell', 'Qwen3.5-4B, Apache 2.0'], + ['Ort', 'auf diesem Server, nur über 127.0.0.1'], + ['Kosten', '0 € – nichts verlässt die Maschine'], + ['Grenzen', 'zwei von vier Kernen, eine Anfrage gleichzeitig, fünf je Person in zehn Minuten'], + ['Zustand', d.beschaeftigt ? 'denkt gerade' : (d.da ? 'bereit' : '–')], + ]) { + const li = el('li'); + li.append(el('span', 'ki-kasten__schild', name), el('span', null, wert)); + daten.append(li); + } + } + + $('ki-schalter').addEventListener('change', async () => { + const an = $('ki-schalter').checked; + $('ki-schalter').disabled = true; + try { + const a = await hole('/workspace/api/ki/schalter', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ an }), + }); + if (!a.ok) { + melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); + $('ki-schalter').checked = !an; + return; + } + melde(''); + await kiZustand(); + } catch { /* umgeleitet */ } finally { $('ki-schalter').disabled = false; } + }); + + /* ---------- Hinweise ----------------------------------------------------- */ + + const HINWEISTEXT = { + aufgaben_ueberfaellig: 'überfällige Aufgaben', aufgaben_heute: 'heute fällige Aufgaben', + aufgaben_review: 'Aufgaben warten auf Freigabe', termine_heute: 'Termine heute', + protokoll_fehlt: 'Gespräche ohne Protokoll', dateien_review: 'Dateien im Review', + bereiche_dringend: 'dringende Punkte in den Bereichen', startcheck_handlung: 'Punkte im Start-Check', + followup_faellig: 'fällige Follow-ups', uebergabe_offen: 'übergebene Leads ohne Creator', + review_faellig: 'überfällige Review-Termine', review_bald: 'Reviews in den nächsten 7 Tagen', + ohne_betreuung: 'Creator ohne zuständige Person', ohne_profil: 'leere Creator-Profile', + ohne_startcheck: 'Creator ohne Start-Check', + }; + + async function hinweise() { + let d; + try { d = await (await hole('/workspace/api/hinweise')).json(); } catch { return; } + const ziel = $('hinweise'); + ziel.textContent = ''; + $('hinweis-zahl').textContent = String(d.hinweise.length); + if (!d.hinweise.length) { + ziel.append(el('p', 'leer-hinweis', 'Nichts offen. Alles läuft.')); + return; + } + const liste = el('div', 'hinweise'); + for (const h of d.hinweise) { + const a = el('a', 'hinweis'); + a.href = h.ziel; + a.dataset.stufe = h.stufe; + a.append(el('span', 'hinweis__zahl', String(h.anzahl))); + a.append(el('span', 'hinweis__text', HINWEISTEXT[h.art] || h.text)); + a.append(el('span', 'hinweis__pfeil', '→')); + liste.append(a); + } + ziel.append(liste); + } + + /* ---------- Verlauf ------------------------------------------------------ */ + + async function verlauf() { + const ziel = $('verlauf'); + let d; + try { + const a = await hole('/workspace/api/verwaltung/protokoll?n=40'); + if (!a.ok) { ziel.textContent = ''; ziel.append(el('p', 'leise', 'Kein Zugriff auf das Protokoll.')); return; } + d = await a.json(); + } catch { return; } + + /* Nur die Eintraege, die tatsaechlich aus einer Automation stammen -- + Anmeldungen und Handarbeit gehoeren hier nicht hin. */ + const eintraege = (d.eintraege || d.protokoll || []).filter((e) => ART[e.aktion]).slice(0, 12); + ziel.textContent = ''; + if (!eintraege.length) { + ziel.append(el('p', 'leer-hinweis', 'Noch nichts passiert, was von allein lief.')); + return; + } + const liste = el('div', 'verlauf'); + for (const e of eintraege) { + const z = el('div', 'verlauf__zeile'); + z.append(el('span', 'verlauf__zeit', zeitpunkt(e.zeitpunkt))); + z.append(el('span', 'verlauf__was', ART[e.aktion])); + z.append(el('span', 'verlauf__wer', [e.wer, e.detail].filter(Boolean).join(' · '))); + liste.append(z); + } + ziel.append(liste); + } + + /* ---------- Start --------------------------------------------------------- */ + + const regeln = $('regeln'); + for (const r of REGELN) { + const k = el('div', 'regel'); + k.append(el('h3', 'regel__name', r.name)); + k.append(el('p', 'regel__text', r.text)); + regeln.append(k); + } + + (async () => { + let ich; + try { + const a = await hole('/workspace/api/ich'); + if (!a.ok) { location.assign('/workspace/'); return; } + ich = await a.json(); + } catch { return; } + $('wer').textContent = ich.name + ' · ' + (ich.rolle_name || ich.rolle); + await Promise.all([kiZustand(), hinweise(), verlauf()]); + })(); +})(); diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index 08a75ec7..1db7b35d 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -15,9 +15,12 @@ /* Bereiche aus dem Konzept, mit der Phase aus dem Umsetzungsplan. `rollen` steuert, wem ein Bereich überhaupt angezeigt wird - schon - jetzt, damit die Trennung von Anfang an sichtbar ist. */ + jetzt, damit die Trennung von Anfang an sichtbar ist. + + "Dashboard" stand hier als Kachel, die man nicht öffnen kann -- man + ist ja schon darauf. Ein Eintrag, der nirgendwohin führt, sieht aus + wie etwas Unfertiges. Raus. */ const BEREICHE = [ - { name: 'Dashboard', phase: 1, rollen: ['admin', 'manager', 'creator', 'scout'] }, { name: 'Personen & Zugänge', phase: 1, rollen: ['admin', 'manager'], ziel: 'personen.html' }, { name: 'Creator-Profile', phase: 1, rollen: ['admin', 'manager', 'scout'], ziel: 'profil.html' }, { name: 'Mein Profil', phase: 1, rollen: ['creator'], ziel: 'profil.html' }, @@ -34,7 +37,7 @@ { name: 'Reports & Review', phase: 2, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'report.html' }, { name: 'Calls & Protokolle', phase: 3, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'calls.html' }, { name: 'Scout-Pipeline', phase: 3, rollen: ['admin', 'manager', 'scout'], ziel: 'scouting.html' }, - { name: 'Automationen', phase: 4, rollen: ['admin', 'manager'] }, + { name: 'Automationen', phase: 4, rollen: ['admin', 'manager'], ziel: 'automation.html' }, ]; const $ = (id) => document.getElementById(id); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 47b26a84..4f852e98 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + +
@@ -134,8 +134,8 @@ - - - + + +