diff --git a/server/index.js b/server/index.js index 4b5cf466..b8de6fd7 100644 --- a/server/index.js +++ b/server/index.js @@ -20,6 +20,7 @@ import { profilRouter } from "./workspace-profil.js"; import { kalenderRouter } from "./workspace-kalender.js"; import { dateienRouter } from "./workspace-dateien.js"; import { bereicheRouter } from "./workspace-bereiche.js"; +import { reportRouter } from "./workspace-reports.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -80,6 +81,7 @@ app.use(profilRouter); app.use(kalenderRouter); app.use(dateienRouter); app.use(bereicheRouter); +app.use(reportRouter); /* 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-reports.js b/server/workspace-reports.js new file mode 100644 index 00000000..73dc81e7 --- /dev/null +++ b/server/workspace-reports.js @@ -0,0 +1,246 @@ +/* ===================================================================== + workspace-reports.js — Reports & Review (Konzept, Seite 16). + + Dieser Bereich fuehrt bewusst KEINE eigenen Eintraege. Er fasst + zusammen, was in Aufgaben, Bereichen, Terminen und Dateien ohnehin + schon steht -- "Fortschritt wird nicht gefuehlt, sondern + nachvollziehbar gemacht". + + Zwei Dinge daraus sind hier woertlich umgesetzt: + + 1. "Vorher / nachher" (Historie). Jede Zahl wird mit demselben, + unmittelbar davorliegenden Zeitraum verglichen. Eine Zahl allein + sagt wenig -- 3 erledigte Aufgaben sind gut oder schlecht, je + nachdem, ob es vorher 1 oder 9 waren. + + 2. "Jeder Review endet mit einer Entscheidung, nicht nur mit einer + Zusammenfassung." Der Report kann deshalb direkt eine Aufgabe + anlegen -- ohne die Seite zu wechseln. + ===================================================================== */ + +import express from "express"; +import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js"; + +export const reportRouter = express.Router(); + +const jetzt = () => new Date().toISOString(); +const tagOffset = (tage) => new Date(Date.now() - tage * 86400_000).toISOString(); + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + /* Scouts haben mit der Creator-Betreuung nichts zu tun. */ + if (person.rolle === "scout") return res.status(404).json({ fehler: "nicht_gefunden" }); + req.person = person; + next(); +} + +reportRouter.use("/workspace/api/report", angemeldet); + +/* Auf welchen Creator schaut der Report? Das Management darf waehlen + (oder alle sehen), ein Creator immer nur sich selbst -- unabhaengig + davon, was im Aufruf steht. */ +function zielCreator(req) { + if (req.person.rolle !== "admin") return req.person.id; + const w = req.query.creator; + if (!w || w === "alle") return null; + const z = Number(w); + return Number.isInteger(z) && z > 0 ? z : null; +} + +reportRouter.get("/workspace/api/report", (req, res) => { + try { + const tage = Math.min(Math.max(Number(req.query.tage) || 30, 7), 180); + const creator = zielCreator(req); + + const jetztIso = jetzt(); + const heute = jetztIso.slice(0, 10); + const abIso = tagOffset(tage); + const davorIso = tagOffset(tage * 2); + const ab = abIso.slice(0, 10); + const davor = davorIso.slice(0, 10); + + /* Einschraenkung auf einen Creator, je Tabelle einmal formuliert. */ + const nurA = creator ? " AND (a.creator_id = ? OR a.verantwortlich_id = ?)" : ""; + const wertA = creator ? [creator, creator] : []; + const nurE = creator ? " AND e.creator_id = ?" : ""; + const wertE = creator ? [creator] : []; + const nurT = creator ? " AND (t.creator_id = ? OR t.teilnehmer_id = ?)" : ""; + const wertT = creator ? [creator, creator] : []; + const nurD = creator ? " AND d.creator_id = ?" : ""; + const wertD = creator ? [creator] : []; + + const zahl = (sql, werte) => db().prepare(sql).get(...werte)?.n ?? 0; + + /* --- Aufgaben ------------------------------------------------------- */ + const erledigt = (von, bis) => zahl( + `SELECT COUNT(*) AS n FROM aufgaben a + WHERE a.erledigt_am IS NOT NULL AND a.erledigt_am >= ? AND a.erledigt_am < ?${nurA}`, + [von, bis, ...wertA]); + const angelegt = (von, bis) => zahl( + `SELECT COUNT(*) AS n FROM aufgaben a WHERE a.erstellt >= ? AND a.erstellt < ?${nurA}`, + [von, bis, ...wertA]); + + const aufgaben = { + erledigt: erledigt(abIso, jetztIso), + erledigt_davor: erledigt(davorIso, abIso), + angelegt: angelegt(abIso, jetztIso), + angelegt_davor: angelegt(davorIso, abIso), + offen: zahl(`SELECT COUNT(*) AS n FROM aufgaben a WHERE a.status <> 'erledigt'${nurA}`, wertA), + ueberfaellig: zahl( + `SELECT COUNT(*) AS n FROM aufgaben a + WHERE a.status <> 'erledigt' AND a.frist IS NOT NULL AND a.frist < ?${nurA}`, + [heute, ...wertA]), + /* "Was blockiert?" aus dem Konzept: was liegt am laengsten offen? */ + aeltester: db().prepare( + `SELECT a.titel, a.frist, a.erstellt FROM aufgaben a + WHERE a.status <> 'erledigt'${nurA} ORDER BY a.erstellt LIMIT 3`).all(...wertA), + }; + + /* --- Bereiche -------------------------------------------------------- */ + const bereiche = {}; + for (const b of ["live", "content", "technik", "community", "schutz"]) { + bereiche[b] = { + neu: zahl( + `SELECT COUNT(*) AS n FROM eintraege e WHERE e.bereich = ? AND e.erstellt >= ?${nurE}`, + [b, abIso, ...wertE]), + neu_davor: zahl( + `SELECT COUNT(*) AS n FROM eintraege e + WHERE e.bereich = ? AND e.erstellt >= ? AND e.erstellt < ?${nurE}`, + [b, davorIso, abIso, ...wertE]), + offen: zahl( + `SELECT COUNT(*) AS n FROM eintraege e + WHERE e.bereich = ? AND e.status = 'offen'${nurE}`, [b, ...wertE]), + dringend: zahl( + `SELECT COUNT(*) AS n FROM eintraege e + WHERE e.bereich = ? AND e.status = 'offen' AND e.dringlichkeit = 'hoch'${nurE}`, + [b, ...wertE]), + }; + } + + /* LIVE-Schnitt: der Review-Score aus dem Konzept, im Vergleich. */ + const schnitt = (von, bis) => { + const r = db().prepare( + `SELECT AVG(e.bewertung) AS s, COUNT(e.bewertung) AS n FROM eintraege e + WHERE e.bereich = 'live' AND e.bewertung IS NOT NULL + AND e.erstellt >= ? AND e.erstellt < ?${nurE}`).get(von, bis, ...wertE); + return { schnitt: r?.s ? Math.round(r.s * 10) / 10 : null, anzahl: r?.n ?? 0 }; + }; + const live = { jetzt: schnitt(abIso, jetztIso), davor: schnitt(davorIso, abIso) }; + + /* --- Termine und Dateien --------------------------------------------- */ + const termine = { + gewesen: zahl( + `SELECT COUNT(*) AS n FROM termine t WHERE t.beginn >= ? AND t.beginn < ?${nurT}`, + [ab, heute, ...wertT]), + kommend: zahl( + `SELECT COUNT(*) AS n FROM termine t WHERE t.beginn >= ? AND t.erledigt = 0${nurT}`, + [heute, ...wertT]), + }; + const dateien = { + neu: zahl(`SELECT COUNT(*) AS n FROM dateien d WHERE d.erstellt >= ?${nurD}`, [abIso, ...wertD]), + freigegeben: zahl( + `SELECT COUNT(*) AS n FROM dateien d WHERE d.status = 'freigegeben'${nurD}`, wertD), + offen_review: zahl( + `SELECT COUNT(*) AS n FROM dateien d WHERE d.status = 'review'${nurD}`, wertD), + }; + + /* --- Ziele aus dem Profil -------------------------------------------- */ + let profil = null; + if (creator) { + profil = db().prepare(` + SELECT plan_prio1, plan_prio2, plan_prio3, naechster_review, plan_start + FROM profile WHERE person_id = ?`).get(creator) || null; + /* Der Review-Termin ist Steuerungswissen -- ein Creator sieht ihn + in seinem Profil auch nicht. */ + if (profil && req.person.rolle !== "admin") { + delete profil.naechster_review; + delete profil.plan_start; + } + } + + res.json({ + zeitraum: { tage, ab, bis: heute }, + creator, + creator_name: creator + ? (db().prepare("SELECT name FROM personen WHERE id = ?").get(creator)?.name ?? null) + : null, + aufgaben, bereiche, live, termine, dateien, profil, + }); + } catch (fehler) { + console.error("[workspace] Report:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* Liste der Creator fuer die Auswahl oben. */ +reportRouter.get("/workspace/api/report/creator", (req, res) => { + try { + if (req.person.rolle !== "admin") { + return res.json({ creator: [{ id: req.person.id, name: req.person.name }] }); + } + res.json({ + creator: db().prepare( + "SELECT id, name FROM personen WHERE rolle = 'creator' AND aktiv = 1 ORDER BY name").all(), + }); + } catch { + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* "Jeder Review endet mit einer Entscheidung." Deshalb kann der Report + direkt eine Aufgabe anlegen, ohne die Seite zu wechseln. Bewusst eine + eigene, sehr enge Route statt eines Verweises auf die Aufgaben- + Schnittstelle: Hier entsteht immer eine Aufgabe im betrachteten + Bereich, mehr braucht es an dieser Stelle nicht. */ +reportRouter.post("/workspace/api/report/entscheidung", (req, res) => { + try { + const herkunft = req.get("origin"); + if (herkunft) { + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + } + + const titel = String(req.body?.titel ?? "").trim(); + if (titel.length < 2 || titel.length > 160) { + return res.status(400).json({ fehler: "Bitte eine kurze, klare Entscheidung eintragen." }); + } + 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." }); + } + + let creator = null; + if (req.person.rolle === "admin") { + const w = req.body?.creator_id; + if (w) { + const z = Number(w); + if (!Number.isInteger(z) || z < 1) return res.status(400).json({ fehler: "Ungültige Zuordnung." }); + if (!db().prepare("SELECT 1 FROM personen WHERE id = ? AND rolle = 'creator'").get(z)) { + return res.status(400).json({ fehler: "Creator existiert nicht." }); + } + creator = z; + } + } else { + creator = req.person.id; + } + + const { lastInsertRowid } = db().prepare(` + INSERT INTO aufgaben + (titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, + frist, erstellt, erstellt_von) + VALUES (?,?,'offen','hoch',?,?,?,?,?)`).run( + titel, "Aus dem Review übernommen.", creator, + creator ?? req.person.id, frist || null, jetzt(), req.person.id); + + protokolliere("review_entscheidung", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${lastInsertRowid} ${titel}`.slice(0, 120), + }); + res.status(201).json({ id: Number(lastInsertRowid) }); + } catch (fehler) { + console.error("[workspace] Entscheidung:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index 0d23c57a..d0ea7848 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -358,6 +358,7 @@ const GESCHUETZT = { "/workspace/kalender.html": null, "/workspace/dateien.html": null, "/workspace/bereich.html": ["admin", "creator"], + "/workspace/report.html": ["admin", "creator"], }; workspaceRouter.use((req, res, next) => { diff --git a/workspace/assets/css/report.css b/workspace/assets/css/report.css new file mode 100644 index 00000000..b552ab44 --- /dev/null +++ b/workspace/assets/css/report.css @@ -0,0 +1,91 @@ +/* =================================================================== + Reports & Review. + =================================================================== */ + +.steuerung { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; } +.steuerung select { + min-width: 160px; padding: 9px 12px; + background: rgba(255, 255, 255, .045); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; +} + +.block { margin-bottom: 34px; } +.block__titel { + margin: 0 0 4px; font-size: .78rem; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; color: var(--akzent); +} +.block__frage { margin: 0 0 14px; font-size: .86rem; color: var(--text-still); } + +.kacheln { + display: grid; gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(158px, 1fr)); +} + +.kachel { + padding: 15px 16px; + background: rgba(255, 255, 255, .03); + border: 1px solid var(--rand); + border-radius: var(--radius-klein); +} +.kachel__wert { display: flex; align-items: baseline; gap: 9px; } +.kachel__zahl { font-size: 1.7rem; font-weight: 700; line-height: 1.1; } +.kachel__name { display: block; margin-top: 3px; font-size: .78rem; color: var(--text-still); } + +/* Vorher/Nachher aus dem Konzept: Eine Zahl allein sagt wenig. */ +.trend { font-size: .78rem; font-weight: 600; white-space: nowrap; } +.trend[data-richtung="hoch"] { color: #79d1a2; } +.trend[data-richtung="runter"] { color: var(--warn); } +.trend[data-richtung="gleich"] { color: var(--text-still); font-weight: 400; } +/* Bei manchen Zahlen ist "mehr" schlecht (z. B. offene Probleme). */ +.trend[data-gut="nein"][data-richtung="hoch"] { color: var(--warn); } +.trend[data-gut="nein"][data-richtung="runter"] { color: #79d1a2; } + +.kachel[data-warn="ja"] { + border-color: rgba(255, 154, 162, .4); + background: rgba(255, 154, 162, .07); +} +.kachel[data-warn="ja"] .kachel__zahl { color: var(--warn); } + +.liste-klein { margin: 0; padding: 0; list-style: none; } +.liste-klein li { + padding: 10px 14px; margin-bottom: 7px; + background: rgba(255, 255, 255, .025); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + font-size: .88rem; +} +.liste-klein .klein { display: block; margin-top: 2px; font-size: .77rem; color: var(--text-still); } + +.leer-hinweis { + padding: 22px; text-align: center; color: var(--text-still); font-size: .88rem; + border: 1px dashed var(--rand); border-radius: var(--radius); +} + +/* ---------- Entscheidung ------------------------------------------------ */ + +.entscheidung { + margin-top: 8px; padding: 22px 24px; + background: linear-gradient(100deg, rgba(63, 189, 245, .09), rgba(138, 118, 255, .05)); + border: 1px solid rgba(63, 189, 245, .35); + border-radius: var(--radius); +} +.entscheidung__titel { margin: 2px 0 6px; font-size: 1.15rem; font-weight: 700; } +.entscheidung__text { margin: 0 0 18px; font-size: .88rem; color: var(--text-leise); max-width: 62ch; } +.entscheidung__raster { + display: grid; gap: 14px; margin-bottom: 6px; + grid-template-columns: minmax(0, 3fr) minmax(0, 1fr); +} +.entscheidung input { + width: 100%; padding: 11px 13px; + background: rgba(255, 255, 255, .05); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; +} +.entscheidung input:focus { + outline: none; border-color: rgba(63, 189, 245, .7); + box-shadow: 0 0 0 3px rgba(63, 189, 245, .16); +} + +@media (max-width: 620px) { + .entscheidung__raster { grid-template-columns: 1fr; } +} diff --git a/workspace/assets/js/report.js b/workspace/assets/js/report.js new file mode 100644 index 00000000..b7c3cc8a --- /dev/null +++ b/workspace/assets/js/report.js @@ -0,0 +1,226 @@ +/* =================================================================== + Reports & Review. + + Die Fragen der Abschnitte stammen woertlich aus dem Konzept, Seite 16: + "Was wurde erledigt? Was blockiert? Was hat funktioniert? Was kommt + naechste Woche?" + =================================================================== */ +(() => { + 'use strict'; + + const $ = (id) => document.getElementById(id); + const BEREICHSNAME = { + live: 'LIVE', content: 'Content', technik: 'Technik', + community: 'Community', schutz: 'Schutz', + }; + + let ich = 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 || ''; }; + const datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : '—'); + + 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'); } + if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('kein Zugriff'); } + return a; + } + + /* Vorher/Nachher. `gut` sagt, ob ein Anstieg erfreulich ist -- bei + offenen Problemen ist er das nicht. */ + function trend(jetzt, davor, gut = true) { + const t = el('span', 'trend'); + t.dataset.gut = gut ? 'ja' : 'nein'; + const diff = jetzt - davor; + if (!davor && !jetzt) { t.dataset.richtung = 'gleich'; t.textContent = '–'; return t; } + if (diff === 0) { t.dataset.richtung = 'gleich'; t.textContent = 'unverändert'; return t; } + t.dataset.richtung = diff > 0 ? 'hoch' : 'runter'; + t.textContent = (diff > 0 ? '▲ +' : '▼ ') + diff + ' ggü. davor'; + return t; + } + + function kachel(zahl, name, { trendEl = null, warn = false } = {}) { + const k = el('div', 'kachel'); + if (warn) k.dataset.warn = 'ja'; + const wert = el('div', 'kachel__wert'); + wert.append(el('span', 'kachel__zahl', String(zahl))); + if (trendEl) wert.append(trendEl); + k.append(wert, el('span', 'kachel__name', name)); + return k; + } + + function block(titel, frage, inhalt) { + const b = el('section', 'block'); + b.append(el('h2', 'block__titel', titel)); + if (frage) b.append(el('p', 'block__frage', frage)); + b.append(inhalt); + return b; + } + + /* ---------- Bericht zeichnen ------------------------------------------- */ + + function zeichne(d) { + const ziel = $('bericht'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + + $('unterzeile').textContent = + `${d.creator_name ? d.creator_name + ' · ' : 'Alle Creator · '}` + + `${datum(d.zeitraum.ab)} bis ${datum(d.zeitraum.bis)}` + + ` · Vergleich mit den ${d.zeitraum.tage} Tagen davor`; + + /* Was wurde erledigt? */ + const k1 = el('div', 'kacheln'); + k1.append(kachel(d.aufgaben.erledigt, 'Aufgaben erledigt', + { trendEl: trend(d.aufgaben.erledigt, d.aufgaben.erledigt_davor) })); + k1.append(kachel(d.aufgaben.angelegt, 'neu angelegt', + { trendEl: trend(d.aufgaben.angelegt, d.aufgaben.angelegt_davor) })); + k1.append(kachel(d.termine.gewesen, 'Termine gewesen')); + k1.append(kachel(d.dateien.freigegeben, 'Dateien freigegeben')); + ziel.append(block('Was wurde erledigt?', null, k1)); + + /* Was blockiert? */ + const k2 = el('div', 'kacheln'); + k2.append(kachel(d.aufgaben.offen, 'Aufgaben offen')); + k2.append(kachel(d.aufgaben.ueberfaellig, 'überfällig', { warn: d.aufgaben.ueberfaellig > 0 })); + const dringend = Object.values(d.bereiche).reduce((s, b) => s + b.dringend, 0); + k2.append(kachel(dringend, 'dringende Punkte', { warn: dringend > 0 })); + k2.append(kachel(d.dateien.offen_review, 'Dateien im Review')); + const b2 = block('Was blockiert?', 'Die drei Aufgaben, die am längsten offen liegen:', k2); + if (d.aufgaben.aeltester.length) { + const liste = el('ul', 'liste-klein'); + for (const a of d.aufgaben.aeltester) { + const li = el('li'); + li.append(document.createTextNode(a.titel)); + li.append(el('span', 'klein', + `offen seit ${datum(a.erstellt)}${a.frist ? ' · Frist ' + datum(a.frist) : ''}`)); + liste.append(li); + } + b2.append(liste); + } else { + b2.append(el('p', 'leer-hinweis', 'Nichts offen – sauber.')); + } + ziel.append(b2); + + /* Was hat funktioniert? */ + const k3 = el('div', 'kacheln'); + const jetztS = d.live.jetzt.schnitt; + const davorS = d.live.davor.schnitt; + const kLive = kachel(jetztS === null ? '–' : String(jetztS).replace('.', ','), + `LIVE-Schnitt aus ${d.live.jetzt.anzahl} Bewertung(en)`); + if (jetztS !== null && davorS !== null) { + kLive.querySelector('.kachel__wert').append( + trend(Math.round(jetztS * 10), Math.round(davorS * 10))); + } + k3.append(kLive); + for (const [b, w] of Object.entries(d.bereiche)) { + k3.append(kachel(w.neu, BEREICHSNAME[b] + ': neue Einträge', + { trendEl: trend(w.neu, w.neu_davor) })); + } + ziel.append(block('Was hat funktioniert?', + 'Bewegung in den Betreuungsbereichen im Vergleich zum Zeitraum davor:', k3)); + + /* Was kommt als Nächstes? */ + const k4 = el('div', 'kacheln'); + k4.append(kachel(d.termine.kommend, 'Termine stehen an')); + for (const [b, w] of Object.entries(d.bereiche)) { + if (w.offen) k4.append(kachel(w.offen, BEREICHSNAME[b] + ': offen', + { warn: w.dringend > 0 })); + } + const b4 = block('Was kommt als Nächstes?', null, k4); + if (d.profil && (d.profil.plan_prio1 || d.profil.plan_prio2 || d.profil.plan_prio3)) { + const liste = el('ul', 'liste-klein'); + for (const [i, t] of [d.profil.plan_prio1, d.profil.plan_prio2, d.profil.plan_prio3].entries()) { + if (!t) continue; + const li = el('li'); + li.append(document.createTextNode(t)); + li.append(el('span', 'klein', `Priorität ${i + 1} aus dem 90-Tage-Plan`)); + liste.append(li); + } + b4.append(liste); + } + if (d.profil?.naechster_review) { + b4.append(el('p', 'block__frage', 'Nächster Review-Termin: ' + datum(d.profil.naechster_review))); + } + ziel.append(b4); + + $('entscheidung-block').hidden = false; + } + + async function laden() { + melde(''); + const creator = $('f-creator').value || 'alle'; + const tage = $('f-tage').value; + try { + const a = await hole(`/workspace/api/report?creator=${encodeURIComponent(creator)}&tage=${tage}`); + if (!a.ok) { melde('Report konnte nicht erstellt werden.'); return; } + zeichne(await a.json()); + } catch { /* umgeleitet */ } + } + + /* ---------- Entscheidung ------------------------------------------------ */ + + $('entscheidung').addEventListener('submit', async (e) => { + e.preventDefault(); + $('e-fehler').textContent = ''; + const knopf = $('e-speichern'); + knopf.disabled = true; + try { + const a = await hole('/workspace/api/report/entscheidung', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + titel: $('e-titel').value, + frist: $('e-frist').value, + creator_id: $('f-creator').value || null, + }), + }); + if (!a.ok) { + $('e-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; + return; + } + $('entscheidung').reset(); + $('e-stand').textContent = 'Als Aufgabe angelegt ✓'; + setTimeout(() => { $('e-stand').textContent = ''; }, 3200); + await laden(); + } catch { /* umgeleitet */ } finally { knopf.disabled = false; } + }); + + /* ---------- Start -------------------------------------------------------- */ + + (async () => { + try { + const a = await hole('/workspace/api/ich'); + if (!a.ok) { location.assign('/workspace/'); return; } + ich = await a.json(); + } catch { return; } + if (ich.rolle === 'scout') { location.assign('/workspace/start.html'); return; } + $('wer').textContent = ich.name + ' · ' + ich.rolle; + + try { + const { creator } = await (await hole('/workspace/api/report/creator')).json(); + if (ich.rolle === 'admin') { + $('creator-block').hidden = false; + const alle = document.createElement('option'); + alle.value = ''; alle.textContent = 'Alle Creator'; + $('f-creator').append(alle); + } + for (const c of creator) { + const o = document.createElement('option'); + o.value = String(c.id); o.textContent = c.name; + $('f-creator').append(o); + } + if (ich.rolle !== 'admin') $('f-creator').value = String(ich.id); + } catch { return; } + + $('f-creator').addEventListener('change', laden); + $('f-tage').addEventListener('change', laden); + await laden(); + })(); +})(); diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index 5fbe3717..a568126d 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -28,7 +28,7 @@ { name: 'Technik', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=technik' }, { name: 'Community', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=community' }, { name: 'Schutz & Regeln', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=schutz' }, - { name: 'Reports & Review', phase: 2, rollen: ['admin', 'creator'] }, + { name: 'Reports & Review', phase: 2, rollen: ['admin', 'creator'], ziel: 'report.html' }, { name: 'Calls', phase: 3, rollen: ['admin', 'creator', 'scout'] }, { name: 'Scout-Pipeline', phase: 3, rollen: ['admin', 'scout'] }, { name: 'Automationen', phase: 4, rollen: ['admin'] }, diff --git a/workspace/report.html b/workspace/report.html new file mode 100644 index 00000000..5b8b8f79 --- /dev/null +++ b/workspace/report.html @@ -0,0 +1,92 @@ + + + + + +Reports & Review · Creator Workspace + + + + + + + + + + + +
+ +

Creator Workspace · Review

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

Reports

+

Review & nächste Schritte

+

Fortschritt wird nicht gefühlt, sondern nachvollziehbar gemacht.

+
+
+ +
+ + +
+
+
+ + + +

Report wird erstellt …

+ + + + +
+ + + + +