Workspace: Reports & Review -- Phase 2 vollstaendig
/workspace/report.html. Fuehrt bewusst KEINE eigenen Eintraege, sondern fasst zusammen, was in Aufgaben, Bereichen, Terminen und Dateien schon steht: "Fortschritt wird nicht gefuehlt, sondern nachvollziehbar gemacht". Die vier Abschnitte sind woertlich die Fragen aus dem Deck, Seite 16: Was wurde erledigt? Was blockiert? Was hat funktioniert? Was kommt als Naechstes? Zwei Punkte daraus sind ernst genommen: 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. Bei Zahlen, wo mehr SCHLECHTER ist (offene Probleme), ist die Trendfarbe umgedreht. 2. "Jeder Review endet mit einer Entscheidung, nicht nur mit einer Zusammenfassung." Der Report legt deshalb direkt eine Aufgabe an -- ohne Seitenwechsel, mit hoher Prioritaet und optionaler Frist. Geprueft: Eintrag im Report -> Aufgabe erscheint im Brett. "Was blockiert?" zeigt zusaetzlich die drei am laengsten offenen Aufgaben mit Namen, nicht nur eine Zahl. Sichtbarkeit: - Scout: 404, sowohl Schnittstelle als auch Seite (leitet weg) - Creator: sieht nur sich. Geprueft -- Luna fordert ?creator=3 (Mika) an und bekommt einen Report ueber SICH, der Parameter wird fuer Nicht-Management ignoriert - Der Review-Termin aus dem Profil ist Steuerungswissen und wird einem Creator auch hier nicht mitgeschickt, genau wie im Profil selbst Damit ist Phase 2 aus dem Konzept vollstaendig.
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user