diff --git a/server/index.js b/server/index.js index c5fc0b12..cda8d81e 100644 --- a/server/index.js +++ b/server/index.js @@ -25,6 +25,7 @@ import { scoutRouter } from "./workspace-scouts.js"; import { callRouter } from "./workspace-calls.js"; import { hinweisRouter } from "./workspace-hinweise.js"; import { sucheRouter } from "./workspace-suche.js"; +import { startcheckRouter } from "./workspace-startcheck.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -90,6 +91,7 @@ app.use(scoutRouter); app.use(callRouter); app.use(hinweisRouter); app.use(sucheRouter); +app.use(startcheckRouter); /* 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-hinweise.js b/server/workspace-hinweise.js index af292b01..70ed7f36 100644 --- a/server/workspace-hinweise.js +++ b/server/workspace-hinweise.js @@ -140,6 +140,29 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => { e.werte)); } + /* Offener Handlungsbedarf aus der Erstanalyse. Sichtbar fuer + Management, zustaendigen Scout und den Creator selbst -- genau + wie der Start-Check. */ + { + let woS = null; + let werteS = []; + if (person.rolle === "admin") woS = "1=1"; + else if (person.rolle === "creator") { woS = "s.creator_id = ?"; werteS = [person.id]; } + else { + const ids = betreuteIds(person); + if (ids.length) { + woS = `s.creator_id IN (${ids.map(() => "?").join(",")})`; + werteS = ids; + } + } + if (woS) { + dazu("startcheck_handlung", "offen", + "Punkt im Start-Check braucht Handlung", "startcheck.html", + zaehle(`SELECT COUNT(*) n FROM startcheck s + WHERE ${woS} AND s.bewertung = 'handlung'`, werteS)); + } + } + /* ---------- Scout-Pipeline ---------- */ if (person.rolle === "admin" || person.rolle === "scout") { const nur = person.rolle === "admin" ? "1=1" : "l.scout_id = ?"; @@ -183,6 +206,14 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => { LEFT JOIN betreuung b ON b.creator_id = p.id WHERE p.rolle = 'creator' AND p.aktiv = 1 AND b.creator_id IS NULL`, [])); + /* Ein Creator ohne Erstanalyse ist die Sorte Luecke, die sonst + niemandem auffaellt -- es fehlt ja nichts, es fing nur nie an. */ + dazu("ohne_startcheck", "ruhig", + "Creator ohne Start-Check", "startcheck.html", + zaehle(`SELECT COUNT(*) n FROM personen p + WHERE p.rolle = 'creator' AND p.aktiv = 1 + AND NOT EXISTS (SELECT 1 FROM startcheck s WHERE s.creator_id = p.id)`, [])); + dazu("ohne_profil", "ruhig", "Creator ohne ausgefülltes Profil", "profil.html", zaehle(`SELECT COUNT(*) n FROM personen p diff --git a/server/workspace-startcheck.js b/server/workspace-startcheck.js new file mode 100644 index 00000000..11b631ff --- /dev/null +++ b/server/workspace-startcheck.js @@ -0,0 +1,291 @@ +/* ===================================================================== + workspace-startcheck.js — Erstanalyse (Konzept, Seite 5, Block 03). + + "Die Betreuung startet strukturiert: Ausgangslage verstehen, Ziele + festlegen und daraus konkrete Arbeitspakete bauen." + + Der letzte Satz ist der entscheidende. Ein Check, der mit einer Note + endet, ist eine Beurteilung. Ein Check, der mit Aufgaben endet, ist + Betreuung. Deshalb laesst sich jeder Punkt mit Handlungsbedarf direkt + in eine Aufgabe verwandeln -- dieselbe Linie wie beim Review + ("endet mit einer Entscheidung") und beim Call ("endet mit To-dos"). + + Die Punkte stehen fest im Code, nicht in der Datenbank. Eine + Erstanalyse, bei der jeder seine eigenen Punkte anlegt, ist keine + Erstanalyse mehr -- man koennte zwei Creator nicht mehr vergleichen, + und genau das ist ihr Zweck. + ===================================================================== */ + +import express from "express"; +import { db, protokolliere, echteIp, sitzungLesen, darfCreator, betreuteIds } from "./workspace.js"; + +export const startcheckRouter = express.Router(); + +/* Die vier Felder aus dem Deck, mit je vier Punkten. Bewusst knapp + gehalten: Eine Liste, die man in einem Gespraech durchgehen kann. */ +export const FELDER = [ + { + schluessel: "profil", + name: "Profil & Auftritt", + punkte: [ + { schluessel: "bild", name: "Profilbild und Name", hinweis: "Erkennt man auf Anhieb, wer das ist?" }, + { schluessel: "bio", name: "Beschreibung / Bio", hinweis: "Steht dort, was einen erwartet?" }, + { schluessel: "kanaele", name: "Verlinkte Kanäle", hinweis: "Führen die Verweise irgendwohin – und stimmen sie noch?" }, + { schluessel: "wiedererkennung", name: "Wiedererkennbarkeit", hinweis: "Farben, Bilder, Sprache – passt das zusammen?" }, + ], + }, + { + schluessel: "live", + name: "LIVE-Struktur", + punkte: [ + { schluessel: "zeiten", name: "Feste Sendezeiten", hinweis: "Weiß die Community, wann sie kommen kann?" }, + { schluessel: "ablauf", name: "Roter Faden im Stream", hinweis: "Gibt es einen erkennbaren Ablauf, oder passiert es einfach?" }, + { schluessel: "begruessung", name: "Umgang mit neuen Zuschauern", hinweis: "Werden Neue angesprochen oder gehen sie unter?" }, + { schluessel: "laenge", name: "Länge und Rhythmus", hinweis: "Passt die Dauer zur Energie – und wird sie durchgehalten?" }, + ], + }, + { + schluessel: "content", + name: "Content-Muster", + punkte: [ + { schluessel: "formate", name: "Wiederkehrende Formate", hinweis: "Gibt es etwas, worauf man sich freuen kann?" }, + { schluessel: "rhythmus", name: "Veröffentlichungsrhythmus", hinweis: "Regelmäßig oder in Schüben?" }, + { schluessel: "einstiege", name: "Einstiege der ersten Sekunden", hinweis: "Bleibt man hängen oder scrollt man weiter?" }, + { schluessel: "verwertung", name: "LIVE wird zu Content", hinweis: "Werden gute Momente aus dem Stream weiterverwendet?" }, + ], + }, + { + schluessel: "community", + name: "Community & Modis", + punkte: [ + { schluessel: "stamm", name: "Stammzuschauer", hinweis: "Gibt es Namen, die immer wieder auftauchen?" }, + { schluessel: "moderation", name: "Moderation besetzt", hinweis: "Ist jemand da, wenn es voll oder ungemütlich wird?" }, + { schluessel: "regeln", name: "Regeln sichtbar", hinweis: "Weiß man, was gilt, bevor etwas passiert?" }, + { schluessel: "stoerer", name: "Umgang mit Störern", hinweis: "Gibt es ein geübtes Vorgehen – oder wird improvisiert?" }, + ], + }, +]; + +const BEWERTUNGEN = new Set(["ok", "mittel", "handlung"]); +const NOTIZ_MAX = 2000; + +/* Nachschlagewerk, damit ein Aufruf nicht irgendeinen Punkt erfinden kann. */ +const GUELTIG = new Map(); +for (const f of FELDER) { + for (const p of f.punkte) GUELTIG.set(`${f.schluessel}:${p.schluessel}`, { feld: f, punkt: p }); +} +const PUNKTE_GESAMT = GUELTIG.size; + +const jetzt = () => new Date().toISOString(); + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +startcheckRouter.use("/workspace/api/startcheck", angemeldet); + +/* Lesen darf: das Management, der zustaendige Scout -- und der Creator + selbst. Der Check ist Teil SEINES Entwicklungsplans ("jeder Creator + bekommt einen eigenen, lebenden Entwicklungsplan"), nicht ein Urteil + hinter seinem Ruecken. Wer etwas festhalten will, das der Creator + nicht sehen soll, hat dafuer die interne Notiz im Profil. + Aendern duerfen ihn nur Management und Scout: Es ist eine + Fremdeinschaetzung, sonst waere es eine Selbsteinschaetzung. */ +function darfLesen(person, creatorId) { + if (person.rolle === "creator") return person.id === creatorId; + return darfCreator(person, creatorId); +} +const darfAendern = (person, creatorId) => + person.rolle !== "creator" && darfCreator(person, creatorId); + +/* ---------- Auswahl der Creator ----------------------------------------- */ + +startcheckRouter.get("/workspace/api/startcheck/creator", (req, res) => { + try { + const person = req.person; + if (person.rolle === "creator") { + return res.json({ creator: [{ id: person.id, name: person.name }], eigen: true }); + } + let nur = ""; + let werte = []; + if (person.rolle === "scout") { + const ids = betreuteIds(person); + if (!ids.length) return res.json({ creator: [], eigen: false }); + nur = ` AND id IN (${ids.map(() => "?").join(",")})`; + werte = ids; + } + res.json({ + creator: db().prepare( + `SELECT id, name FROM personen WHERE rolle = 'creator' AND aktiv = 1${nur} + ORDER BY name`).all(...werte), + eigen: false, + }); + } catch (fehler) { + console.error("[workspace] Start-Check Creator:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Lesen -------------------------------------------------------- */ + +startcheckRouter.get("/workspace/api/startcheck/:id", (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + if (!darfLesen(req.person, id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const person = db().prepare( + "SELECT id, name, rolle FROM personen WHERE id = ?").get(id); + if (!person || person.rolle !== "creator") { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + + const zeilen = db().prepare(` + SELECT s.feld, s.punkt, s.bewertung, s.notiz, s.geaendert, p.name AS von + FROM startcheck s LEFT JOIN personen p ON p.id = s.geaendert_von + WHERE s.creator_id = ?`).all(id); + const karte = new Map(zeilen.map((z) => [`${z.feld}:${z.punkt}`, z])); + + const felder = FELDER.map((f) => ({ + schluessel: f.schluessel, + name: f.name, + punkte: f.punkte.map((p) => { + const z = karte.get(`${f.schluessel}:${p.schluessel}`); + return { + schluessel: p.schluessel, name: p.name, hinweis: p.hinweis, + bewertung: z?.bewertung ?? null, notiz: z?.notiz ?? null, + geaendert: z?.geaendert ?? null, von: z?.von ?? null, + }; + }), + })); + + const geprueft = zeilen.filter((z) => BEWERTUNGEN.has(z.bewertung)).length; + res.json({ + creator: { id: person.id, name: person.name }, + felder, + geprueft, + gesamt: PUNKTE_GESAMT, + handlungsbedarf: zeilen.filter((z) => z.bewertung === "handlung").length, + darf_aendern: darfAendern(req.person, id), + }); + } catch (fehler) { + console.error("[workspace] Start-Check lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Einen Punkt setzen ------------------------------------------- */ + +startcheckRouter.put("/workspace/api/startcheck/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + /* 404 statt 403 auch hier: Wer nicht lesen darf, soll nicht ueber + die Fehlermeldung erfahren, dass es die Person gibt. */ + if (!darfLesen(req.person, id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!darfAendern(req.person, id)) { + return res.status(403).json({ fehler: "Den Start-Check füllt die Betreuung aus." }); + } + + const feld = String(req.body?.feld ?? ""); + const punkt = String(req.body?.punkt ?? ""); + if (!GUELTIG.has(`${feld}:${punkt}`)) { + return res.status(400).json({ fehler: "Diesen Punkt gibt es nicht." }); + } + + const b = req.body?.bewertung; + const bewertung = b === null || b === "" ? null : String(b); + if (bewertung !== null && !BEWERTUNGEN.has(bewertung)) { + return res.status(400).json({ fehler: "Unbekannte Bewertung." }); + } + const notiz = String(req.body?.notiz ?? "").trim().slice(0, NOTIZ_MAX) || null; + + /* Ein Punkt ohne Bewertung und ohne Notiz ist kein Eintrag, sondern + ein zurueckgenommener -- der verschwindet, statt als leere Zeile + stehen zu bleiben. */ + if (bewertung === null && !notiz) { + db().prepare("DELETE FROM startcheck WHERE creator_id = ? AND feld = ? AND punkt = ?") + .run(id, feld, punkt); + } else { + db().prepare(` + INSERT INTO startcheck (creator_id, feld, punkt, bewertung, notiz, geaendert, geaendert_von) + VALUES (?,?,?,?,?,?,?) + ON CONFLICT(creator_id, feld, punkt) DO UPDATE SET + bewertung = excluded.bewertung, notiz = excluded.notiz, + geaendert = excluded.geaendert, geaendert_von = excluded.geaendert_von`).run( + id, feld, punkt, bewertung, notiz, jetzt(), req.person.id); + } + + protokolliere("startcheck_gesetzt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Creator #${id} ${feld}/${punkt} = ${bewertung ?? "offen"}`.slice(0, 120), + }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Start-Check setzen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Aus einem Befund eine Aufgabe machen ------------------------- + "... und daraus konkrete Arbeitspakete bauen." Ohne diesen Schritt + waere der Check eine Beurteilung statt Betreuung. */ + +startcheckRouter.post("/workspace/api/startcheck/:id/aufgabe", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + if (!darfLesen(req.person, id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!darfAendern(req.person, id)) { + return res.status(403).json({ fehler: "nicht_erlaubt" }); + } + + const feld = String(req.body?.feld ?? ""); + const punkt = String(req.body?.punkt ?? ""); + const eintrag = GUELTIG.get(`${feld}:${punkt}`); + if (!eintrag) return res.status(400).json({ fehler: "Diesen Punkt gibt es nicht." }); + + const titel = String(req.body?.titel ?? "").trim().slice(0, 160); + if (titel.length < 3) return res.status(400).json({ fehler: "Was genau ist zu tun?" }); + + const frist = String(req.body?.frist ?? "").trim(); + if (frist && (!/^\d{4}-\d{2}-\d{2}$/.test(frist) || Number.isNaN(Date.parse(frist)))) { + return res.status(400).json({ fehler: "Frist ist kein gültiges Datum." }); + } + + const gespeichert = db().prepare( + "SELECT bewertung FROM startcheck WHERE creator_id = ? AND feld = ? AND punkt = ?") + .get(id, feld, punkt); + const prio = gespeichert?.bewertung === "handlung" ? "hoch" : "mittel"; + + const { lastInsertRowid } = db().prepare(` + INSERT INTO aufgaben + (titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, + frist, erstellt, erstellt_von) + VALUES (?,?,'offen',?,?,?,?,?,?)`).run( + titel, + `Aus dem Start-Check: ${eintrag.feld.name} – ${eintrag.punkt.name}`, + prio, id, id, frist || null, jetzt(), req.person.id); + + protokolliere("startcheck_aufgabe", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${lastInsertRowid} ${titel}`.slice(0, 120), + }); + res.status(201).json({ id: Number(lastInsertRowid), prioritaet: prio }); + } catch (fehler) { + console.error("[workspace] Start-Check Aufgabe:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index f4d0b3ee..0b54e9a3 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -222,6 +222,24 @@ export function db() { ); CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id); + /* Start-Check / Erstanalyse (Konzept, Seite 5). Eine Zeile je + geprueftem Punkt -- ungeprueft heisst: gar keine Zeile. So sieht + man am Bestand sofort, wie weit die Analyse ist, ohne leere + Platzhalter mitschleppen zu muessen. + Die Punkte selbst stehen im Code, nicht hier: Waeren sie + Datensaetze, koennte jeder eigene anlegen -- und zwei Creator + waeren nicht mehr vergleichbar, was der ganze Zweck ist. */ + CREATE TABLE IF NOT EXISTS startcheck ( + creator_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + feld TEXT NOT NULL, + punkt TEXT NOT NULL, + bewertung TEXT CHECK (bewertung IS NULL OR bewertung IN ('ok','mittel','handlung')), + notiz TEXT, + geaendert TEXT NOT NULL, + geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + PRIMARY KEY (creator_id, feld, punkt) + ); + /* Betreuung: wer kuemmert sich um welchen Creator. Scouts betreuen Creator wie das Management, aber nur die ihnen zugeteilten -- deshalb braucht es eine ausdrueckliche Zuordnung @@ -428,6 +446,7 @@ const GESCHUETZT = { "/workspace/report.html": ["admin", "creator", "scout"], "/workspace/scouting.html": ["admin", "scout"], "/workspace/calls.html": null, + "/workspace/startcheck.html": null, }; workspaceRouter.use((req, res, next) => { diff --git a/workspace/assets/css/startcheck.css b/workspace/assets/css/startcheck.css new file mode 100644 index 00000000..74d06fe9 --- /dev/null +++ b/workspace/assets/css/startcheck.css @@ -0,0 +1,168 @@ +/* =================================================================== + Start-Check / Erstanalyse. + + Drei Bewertungsstufen, aber nur eine sticht hervor. "Passt" darf + ruhig unauffaellig sein -- man sucht beim Durchgehen nicht das, was + in Ordnung ist. + =================================================================== */ + +.steuerung { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; } +.steuerung select { + min-width: 170px; padding: 9px 12px; + background: rgba(255, 255, 255, .045); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; +} + +/* ---------- Fortschritt ---------------------------------------------------- */ + +.fortschritt { + margin-bottom: 30px; padding: 16px 18px; + background: rgba(255, 255, 255, .03); + border: 1px solid var(--rand); border-radius: var(--radius); +} +.fortschritt__kopf { display: flex; align-items: baseline; gap: 8px; } +.fortschritt__zahl { font-size: 1.3rem; font-weight: 700; } +.fortschritt__text { font-size: .84rem; color: var(--text-still); } +.fortschritt__balken { + height: 6px; margin: 10px 0 8px; overflow: hidden; + background: rgba(255, 255, 255, .07); border-radius: 999px; +} +.fortschritt__fuellung { + height: 100%; width: 0; + background: linear-gradient(90deg, var(--akzent), var(--violett)); + border-radius: 999px; + transition: width .3s ease; +} +.fortschritt[data-stufe="handlung"] { border-color: rgba(255, 154, 162, .3); } +.fortschritt[data-stufe="handlung"] .fortschritt__fuellung { + background: linear-gradient(90deg, var(--warn), #e08a90); +} +.fortschritt[data-stufe="fertig"] .fortschritt__fuellung { + background: linear-gradient(90deg, #79d1a2, #5fb98c); +} +.fortschritt__hinweis { margin: 0; font-size: .85rem; color: var(--text-leise); } + +/* ---------- Felder --------------------------------------------------------- */ + +.feld-block { margin-bottom: 30px; } +.feld-block__kopf { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } +.feld-block__titel { + margin: 0; font-size: .78rem; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; color: var(--akzent); +} +.feld-block__zahl { + padding: 1px 8px; font-size: .74rem; font-weight: 700; color: var(--text-still); + background: rgba(255, 255, 255, .06); border-radius: 999px; +} +.feld-block__zahl[data-fertig="ja"] { + color: #79d1a2; background: rgba(121, 209, 162, .12); +} +.feld-block__punkte { display: grid; gap: 10px; } + +/* ---------- Punkt ---------------------------------------------------------- */ + +.punkt { + position: relative; + padding: 14px 17px 14px 20px; + background: rgba(255, 255, 255, .028); + border: 1px solid var(--rand); + border-radius: var(--radius-klein); + transition: border-color .18s ease; +} +.punkt::before { + content: ""; position: absolute; left: 0; top: 10px; bottom: 10px; width: 2px; + border-radius: 0 2px 2px 0; + background: rgba(255, 255, 255, .1); +} +.punkt[data-bewertung="ok"]::before { background: #79d1a2; opacity: .55; } +.punkt[data-bewertung="mittel"]::before { background: #f0c48a; } +.punkt[data-bewertung="handlung"]::before { background: var(--warn); } +.punkt[data-bewertung="handlung"] { + border-color: rgba(255, 154, 162, .28); + background: rgba(255, 154, 162, .045); +} + +.punkt__kopf { + display: flex; gap: 16px; align-items: flex-start; + justify-content: space-between; flex-wrap: wrap; +} +.punkt__name { margin: 0; font-size: .97rem; font-weight: 650; } +.punkt__hinweis { margin: 2px 0 0; font-size: .81rem; color: var(--text-still); max-width: 56ch; } + +/* Bewertungsknöpfe */ +.stufen { display: flex; gap: 5px; flex: none; } +.stufe-knopf { + padding: 6px 11px; cursor: pointer; + background: transparent; + border: 1px solid var(--rand); border-radius: 999px; + color: var(--text-still); font: inherit; font-size: .77rem; + transition: color .16s ease, border-color .16s ease, background .16s ease; +} +.stufe-knopf:hover { color: var(--text); border-color: rgba(255, 255, 255, .24); } +.stufe-knopf[data-aktiv="ja"][data-wert="ok"] { + color: #79d1a2; border-color: rgba(121, 209, 162, .45); background: rgba(121, 209, 162, .12); +} +.stufe-knopf[data-aktiv="ja"][data-wert="mittel"] { + color: #f0c48a; border-color: rgba(240, 196, 138, .45); background: rgba(240, 196, 138, .12); +} +.stufe-knopf[data-aktiv="ja"][data-wert="handlung"] { + color: #ffb4b9; border-color: rgba(255, 154, 162, .5); background: rgba(255, 154, 162, .14); + font-weight: 600; +} + +.stufe-marke { + flex: none; padding: 4px 11px; border-radius: 999px; + font-size: .75rem; font-weight: 600; + border: 1px solid var(--rand); color: var(--text-still); +} +.stufe-marke[data-wert="ok"] { color: #79d1a2; border-color: rgba(121, 209, 162, .4); } +.stufe-marke[data-wert="mittel"] { color: #f0c48a; border-color: rgba(240, 196, 138, .4); } +.stufe-marke[data-wert="handlung"] { color: #ffb4b9; border-color: rgba(255, 154, 162, .45); } + +.punkt__notiz { + width: 100%; margin-top: 11px; padding: 9px 12px; + background: rgba(255, 255, 255, .04); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; font-size: .87rem; resize: vertical; +} +.punkt__notiz:focus { + outline: none; border-color: rgba(63, 189, 245, .6); + box-shadow: 0 0 0 3px rgba(63, 189, 245, .13); +} +.punkt__notiz-lesen { + margin: 10px 0 0; font-size: .87rem; color: var(--text-leise); + white-space: pre-wrap; max-width: 74ch; +} +.punkt__quelle { margin: 8px 0 0; font-size: .74rem; color: var(--text-still); } + +/* ---------- Arbeitspaket ---------------------------------------------------- */ + +.paket { margin-top: 11px; } +.paket__form { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 7px; } +.paket__titel, .paket__frist { + padding: 9px 11px; + background: rgba(255, 255, 255, .05); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; font-size: .87rem; +} +.paket__titel:focus, .paket__frist:focus { + outline: none; border-color: rgba(63, 189, 245, .6); + box-shadow: 0 0 0 3px rgba(63, 189, 245, .13); +} + +.leer-hinweis { + padding: 30px; text-align: center; color: var(--text-still); font-size: .9rem; + border: 1px dashed var(--rand); border-radius: var(--radius); +} + +@media (max-width: 620px) { + .punkt__kopf { flex-direction: column; } + .stufen { width: 100%; } + .stufe-knopf { flex: 1; } + .paket__form { grid-template-columns: 1fr; } +} + +@media (prefers-reduced-motion: reduce) { + .punkt, .stufe-knopf, .fortschritt__fuellung { transition: none; } +} diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index 9d81cfa4..2934048a 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -20,6 +20,7 @@ { name: 'Personen & Zugänge', phase: 1, rollen: ['admin'], ziel: 'personen.html' }, { name: 'Creator-Profile', phase: 1, rollen: ['admin', 'scout'], ziel: 'profil.html' }, { name: 'Mein Profil', phase: 1, rollen: ['creator'], ziel: 'profil.html' }, + { name: 'Start-Check', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'startcheck.html' }, { name: 'Aufgaben', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'aufgaben.html' }, { name: 'Kalender', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'kalender.html' }, { name: 'Dateien', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'dateien.html' }, @@ -102,6 +103,8 @@ 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`, + ohne_startcheck: (n) => `${n} Creator ${n === 1 ? 'hat' : 'haben'} noch keinen Start-Check`, + startcheck_handlung: (n) => `${n} ${n === 1 ? 'Punkt im Start-Check braucht' : 'Punkte im Start-Check brauchen'} Handlung`, }; async function hinweiseLaden() { diff --git a/workspace/assets/js/startcheck.js b/workspace/assets/js/startcheck.js new file mode 100644 index 00000000..d4cc999e --- /dev/null +++ b/workspace/assets/js/startcheck.js @@ -0,0 +1,289 @@ +/* =================================================================== + Start-Check / Erstanalyse (Konzept, Seite 5). + + Bewusst ohne Speichern-Knopf: Jede Bewertung wird sofort gesichert. + Eine Erstanalyse geht man im Gespraech durch -- da will niemand am + Ende noch daran denken, unten auf einen Knopf zu druecken. + + Und: Jeder Punkt mit Handlungsbedarf bietet direkt an, daraus eine + Aufgabe zu machen. "Ausgangslage verstehen UND daraus konkrete + Arbeitspakete bauen" -- der zweite Teil ist der, der zaehlt. + =================================================================== */ +(() => { + 'use strict'; + + const $ = (id) => document.getElementById(id); + const STUFEN = [ + { wert: 'ok', name: 'Passt' }, + { wert: 'mittel', name: 'Ausbaufähig' }, + { wert: 'handlung', name: 'Handlungsbedarf' }, + ]; + + let ich = null; + let daten = null; + let aktuell = null; + + 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; + } + + async function setze(feld, punkt, aenderung) { + try { + const a = await hole('/workspace/api/startcheck/' + aktuell, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ feld, punkt, ...aenderung }), + }); + if (!a.ok) { + melde((await a.json().catch(() => ({}))).fehler || 'Konnte nicht gespeichert werden.'); + return false; + } + melde(''); + return true; + } catch { return false; } + } + + /* ---------- Ein Punkt ---------------------------------------------------- */ + + function punktZeile(feld, p) { + const k = el('div', 'punkt'); + if (p.bewertung) k.dataset.bewertung = p.bewertung; + + const kopf = el('div', 'punkt__kopf'); + const links = el('div'); + links.append(el('h3', 'punkt__name', p.name)); + links.append(el('p', 'punkt__hinweis', p.hinweis)); + kopf.append(links); + + /* Bewertung */ + if (daten.darf_aendern) { + const wahl = el('div', 'stufen'); + wahl.setAttribute('role', 'group'); + wahl.setAttribute('aria-label', 'Bewertung für ' + p.name); + for (const s of STUFEN) { + const b = el('button', 'stufe-knopf', s.name); + b.type = 'button'; + b.dataset.wert = s.wert; + if (p.bewertung === s.wert) b.dataset.aktiv = 'ja'; + b.addEventListener('click', async () => { + /* Nochmal auf dieselbe Stufe = zurücknehmen. */ + const neu = p.bewertung === s.wert ? null : s.wert; + if (await setze(feld.schluessel, p.schluessel, { bewertung: neu, notiz: p.notiz })) { + await laden(); + } + }); + wahl.append(b); + } + kopf.append(wahl); + } else if (p.bewertung) { + const m = el('span', 'stufe-marke', STUFEN.find((s) => s.wert === p.bewertung)?.name); + m.dataset.wert = p.bewertung; + kopf.append(m); + } + k.append(kopf); + + /* Notiz erst, wenn bewertet wurde. Sechzehn offene Textfelder + machen aus einer Checkliste eine Wand -- und vor der Bewertung + hat man ohnehin nichts zu notieren. */ + if (daten.darf_aendern && (p.bewertung || p.notiz)) { + const n = el('textarea', 'punkt__notiz'); + n.rows = 2; + n.maxLength = 2000; + n.placeholder = 'Was ist dir aufgefallen?'; + n.value = p.notiz || ''; + let warte = null; + n.addEventListener('input', () => { + clearTimeout(warte); + warte = setTimeout(() => { + setze(feld.schluessel, p.schluessel, { bewertung: p.bewertung, notiz: n.value }); + p.notiz = n.value; + }, 700); + }); + k.append(n); + } else if (p.notiz) { + k.append(el('p', 'punkt__notiz-lesen', p.notiz)); + } + + /* Aus dem Befund ein Arbeitspaket machen. */ + if (daten.darf_aendern && (p.bewertung === 'handlung' || p.bewertung === 'mittel')) { + k.append(aufgabenTeil(feld, p)); + } + + if (p.von && p.geaendert) { + k.append(el('p', 'punkt__quelle', + `${p.von} · ${p.geaendert.slice(0, 10).split('-').reverse().join('.')}`)); + } + return k; + } + + function aufgabenTeil(feld, p) { + const box = el('div', 'paket'); + const auf = el('button', 'knopf-still', '+ Daraus eine Aufgabe machen'); + auf.type = 'button'; + + const form = el('form', 'paket__form'); + form.hidden = true; + const titel = el('input', 'paket__titel'); + titel.type = 'text'; + titel.maxLength = 160; + titel.placeholder = 'Was genau ist zu tun?'; + const frist = el('input', 'paket__frist'); + frist.type = 'date'; + frist.title = 'Frist (optional)'; + const ok = el('button', 'knopf knopf--klein', 'Anlegen'); + ok.type = 'submit'; + form.append(titel, frist, ok); + + const stand = el('span', 'leise'); + + auf.addEventListener('click', () => { + form.hidden = false; + auf.hidden = true; + /* Ein sinnvoller Vorschlag statt eines leeren Feldes -- die Notiz + ist meistens schon die halbe Aufgabe. */ + titel.value = p.notiz ? p.notiz.split(/[.\n]/)[0].trim().slice(0, 160) : p.name; + titel.focus(); + titel.select(); + }); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + ok.disabled = true; + try { + const a = await hole(`/workspace/api/startcheck/${aktuell}/aufgabe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + feld: feld.schluessel, punkt: p.schluessel, + titel: titel.value, frist: frist.value, + }), + }); + if (!a.ok) { + melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); + return; + } + const r = await a.json(); + form.hidden = true; + stand.textContent = `Im Aufgabenbrett angelegt (Priorität ${r.prioritaet}) ✓`; + setTimeout(() => { stand.textContent = ''; auf.hidden = false; }, 4000); + } catch { /* umgeleitet */ } finally { ok.disabled = false; } + }); + + box.append(auf, form, stand); + return box; + } + + /* ---------- Zeichnen ----------------------------------------------------- */ + + function zeichne() { + const ziel = $('felder'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + + for (const f of daten.felder) { + const s = el('section', 'feld-block'); + const kopf = el('div', 'feld-block__kopf'); + kopf.append(el('h2', 'feld-block__titel', f.name)); + const fertig = f.punkte.filter((p) => p.bewertung).length; + const z = el('span', 'feld-block__zahl', `${fertig}/${f.punkte.length}`); + if (fertig === f.punkte.length) z.dataset.fertig = 'ja'; + kopf.append(z); + s.append(kopf); + const box = el('div', 'feld-block__punkte'); + for (const p of f.punkte) box.append(punktZeile(f, p)); + s.append(box); + ziel.append(s); + } + + /* Fortschritt */ + $('fortschritt').hidden = false; + $('f-zahl').textContent = `${daten.geprueft} von ${daten.gesamt}`; + const anteil = Math.round((daten.geprueft / daten.gesamt) * 100); + $('f-balken').style.width = anteil + '%'; + const aussen = $('f-balken-aussen'); + aussen.setAttribute('aria-valuenow', String(daten.geprueft)); + aussen.setAttribute('aria-valuemax', String(daten.gesamt)); + + const h = $('f-hinweis'); + if (daten.geprueft === 0) { + h.textContent = daten.darf_aendern + ? 'Noch nichts geprüft. Am besten im Gespräch durchgehen – gespeichert wird sofort.' + : 'Die Erstanalyse wurde noch nicht ausgefüllt.'; + } else if (daten.handlungsbedarf) { + h.textContent = `${daten.handlungsbedarf} ${daten.handlungsbedarf === 1 + ? 'Punkt braucht' : 'Punkte brauchen'} Handlung` + + (daten.darf_aendern ? ' – daraus lassen sich direkt Aufgaben machen.' : '.'); + } else if (daten.geprueft === daten.gesamt) { + h.textContent = 'Vollständig geprüft, nichts Dringendes offen.'; + } else { + h.textContent = ''; + } + $('fortschritt').dataset.stufe = daten.handlungsbedarf ? 'handlung' + : daten.geprueft === daten.gesamt ? 'fertig' : 'laeuft'; + } + + async function laden() { + try { + const a = await hole('/workspace/api/startcheck/' + aktuell); + if (a.status === 404) { + $('felder').textContent = ''; + $('felder').append(el('p', 'leer-hinweis', 'Dieser Start-Check ist für dich nicht sichtbar.')); + return; + } + if (!a.ok) { melde('Start-Check konnte nicht geladen werden.'); return; } + daten = await a.json(); + zeichne(); + } catch { /* umgeleitet */ } + } + + /* ---------- Start --------------------------------------------------------- */ + + (async () => { + 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; + + let liste; + try { liste = await (await hole('/workspace/api/startcheck/creator')).json(); } catch { return; } + + if (!liste.creator.length) { + $('felder').textContent = ''; + $('felder').append(el('p', 'leer-hinweis', ich.rolle === 'scout' + ? 'Dir ist noch kein Creator zugeteilt. Das Management legt fest, wen du betreust.' + : 'Es gibt noch keinen Creator.')); + return; + } + + if (!liste.eigen) { + $('creator-block').hidden = false; + for (const c of liste.creator) { + const o = document.createElement('option'); + o.value = String(c.id); o.textContent = c.name; + $('f-creator').append(o); + } + $('f-creator').addEventListener('change', () => { + aktuell = Number($('f-creator').value); + laden(); + }); + } else { + $('unterzeile').textContent = + 'Die Erstanalyse zu deinem Kanal – Grundlage für deinen 90-Tage-Plan.'; + } + + aktuell = liste.creator[0].id; + await laden(); + })(); +})(); diff --git a/workspace/startcheck.html b/workspace/startcheck.html new file mode 100644 index 00000000..65116bb0 --- /dev/null +++ b/workspace/startcheck.html @@ -0,0 +1,71 @@ + + + + + +Start-Check · Creator Workspace + + + + + + + + + + + +
+ +

Creator Workspace · Start-Check

+
+ … + +
+
+ +
+ +
+
+

Onboarding · Erstanalyse

+

Start-Check

+

+ Ausgangslage verstehen – und daraus konkrete Arbeitspakete bauen. +

+
+
+ +
+
+ + + + + +

Start-Check wird geladen …

+ +
+ + + + +