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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Reports
+
Review & nächste Schritte
+
Fortschritt wird nicht gefühlt, sondern nachvollziehbar gemacht.
+
+
+
+ Creator
+
+
+
+ Zeitraum
+
+ 7 Tage
+ 30 Tage
+ 90 Tage
+
+
+
+
+
+
+
+
+
+
+
+ Abschluss
+ Was ist die Entscheidung?
+
+ Ein Review endet nicht mit einer Zusammenfassung, sondern mit einem nächsten Schritt.
+ Was hier steht, wird sofort als Aufgabe mit hoher Priorität angelegt.
+
+
+
+
+
+
+
+
+
+