Aufgaben
+Offen. In Arbeit. Review. Erledigt.
+Aufgaben werden geladen …
+diff --git a/server/index.js b/server/index.js index 7e42cc9a..806372e3 100644 --- a/server/index.js +++ b/server/index.js @@ -14,6 +14,7 @@ import { gateMiddleware } from "./gate.js"; import { webdesignGate } from "./webdesign-gate.js"; import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js"; import { workspaceRouter } from "./workspace.js"; +import { aufgabenRouter } from "./workspace-aufgaben.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -68,6 +69,7 @@ app.use(webdesignGate); Es öffnet beim Laden keine Datenbank, und jede Route fängt ihre Fehler selbst ab (Begründung im Kopf von workspace.js). */ app.use(workspaceRouter); +app.use(aufgabenRouter); /* 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-aufgaben.js b/server/workspace-aufgaben.js new file mode 100644 index 00000000..feb34cd7 --- /dev/null +++ b/server/workspace-aufgaben.js @@ -0,0 +1,263 @@ +/* ===================================================================== + workspace-aufgaben.js — Aufgaben und Dashboard-Zahlen für /workspace. + + Kernpunkt dieses Moduls ist die Datentrennung aus dem Konzept: + "Creator sehen ihren Bereich. Scouts sehen nur ihre Pipeline." + Sie wird AUSSCHLIESSLICH serverseitig durchgesetzt -- in jeder Abfrage, + nicht im Browser. Ein manipulierter Aufruf bekommt dadurch keine + fremden Daten, egal was er behauptet. + ===================================================================== */ + +import express from "express"; +import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js"; + +export const aufgabenRouter = express.Router(); + +const STATUS = ["offen", "arbeit", "review", "erledigt"]; +const PRIORITAETEN = ["hoch", "mittel", "niedrig"]; +const TITEL_MAX = 160; +const TEXT_MAX = 4000; + +const jetzt = () => new Date().toISOString(); + +/* ---------- Schranke ---------------------------------------------------- */ + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +/* Schutz gegen Anfragen von fremden Seiten. SameSite=lax verhindert das + meiste schon, aber nur solange sich der Browser daran hält. Bei allem, + was Daten verändert, wird zusätzlich geprüft, dass die Anfrage von + dieser Domain kommt. Fehlt der Kopf ganz (z. B. bei curl), ist es kein + Browser-Angriff über eine fremde Seite -- dann zählt allein das Cookie. */ +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(); +} + +aufgabenRouter.use("/workspace/api", angemeldet); + +/* ---------- Sichtbarkeit ------------------------------------------------ */ + +/* Liefert WHERE-Bedingung und Werte, passend zur Rolle. An genau einer + Stelle definiert, damit keine Abfrage sie versehentlich vergisst. */ +function sichtbar(person) { + switch (person.rolle) { + case "admin": + return { wo: "1=1", werte: [] }; + case "creator": + return { wo: "(a.creator_id = ? OR a.verantwortlich_id = ?)", werte: [person.id, person.id] }; + case "scout": + return { wo: "a.verantwortlich_id = ?", werte: [person.id] }; + default: + return { wo: "0=1", werte: [] }; // unbekannte Rolle sieht nichts + } +} + +function darfAendern(person, aufgabe) { + if (person.rolle === "admin") return true; + return aufgabe.creator_id === person.id || aufgabe.verantwortlich_id === person.id; +} + +const SPALTEN = ` + a.id, a.titel, a.beschreibung, a.status, a.prioritaet, a.frist, + a.creator_id, a.verantwortlich_id, a.erstellt, a.geaendert, a.erledigt_am, + pc.name AS creator_name, pv.name AS verantwortlich_name`; + +const VERBUND = ` + FROM aufgaben a + LEFT JOIN personen pc ON pc.id = a.creator_id + LEFT JOIN personen pv ON pv.id = a.verantwortlich_id`; + +/* ---------- Lesen ------------------------------------------------------- */ + +aufgabenRouter.get("/workspace/api/aufgaben", (req, res) => { + try { + const { wo, werte } = sichtbar(req.person); + const reihen = db().prepare(` + SELECT ${SPALTEN} ${VERBUND} + WHERE ${wo} + ORDER BY + CASE a.prioritaet WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END, + CASE WHEN a.frist IS NULL THEN 1 ELSE 0 END, a.frist, + a.id DESC`).all(...werte); + res.json({ aufgaben: reihen }); + } catch (fehler) { + console.error("[workspace] Aufgaben lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +aufgabenRouter.get("/workspace/api/uebersicht", (req, res) => { + try { + const { wo, werte } = sichtbar(req.person); + const zaehle = (zusatz, extra = []) => db() + .prepare(`SELECT COUNT(*) AS n ${VERBUND} WHERE ${wo}${zusatz}`) + .get(...werte, ...extra).n; + + const heute = jetzt().slice(0, 10); + res.json({ + offen: zaehle(" AND a.status = 'offen'"), + arbeit: zaehle(" AND a.status = 'arbeit'"), + review: zaehle(" AND a.status = 'review'"), + erledigt: zaehle(" AND a.status = 'erledigt'"), + /* Überfällig = Frist vorbei und noch nicht erledigt. Genau die Zahl, + die das Konzept auf dem Dashboard sehen will. */ + ueberfaellig: zaehle(" AND a.status <> 'erledigt' AND a.frist IS NOT NULL AND a.frist < ?", [heute]), + heute: zaehle(" AND a.status <> 'erledigt' AND a.frist = ?", [heute]), + }); + } catch (fehler) { + console.error("[workspace] Übersicht:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* Für die Zuweisung: wen darf ich überhaupt eintragen? Scouts bekommen + die Liste bewusst nicht -- sie sollen keine fremden Namen sehen. */ +aufgabenRouter.get("/workspace/api/personen", (req, res) => { + try { + if (req.person.rolle === "admin") { + return res.json({ + personen: db().prepare( + "SELECT id, name, rolle FROM personen WHERE aktiv = 1 ORDER BY rolle, name").all(), + }); + } + res.json({ personen: [{ id: req.person.id, name: req.person.name, rolle: req.person.rolle }] }); + } catch { + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Anlegen und Ändern ------------------------------------------ */ + +function pruefeFelder(körper, { neu }) { + const fehler = []; + const aus = {}; + + if (neu || körper.titel !== undefined) { + const titel = String(körper.titel ?? "").trim(); + if (titel.length < 2) fehler.push("Titel fehlt."); + else if (titel.length > TITEL_MAX) fehler.push(`Titel ist länger als ${TITEL_MAX} Zeichen.`); + else aus.titel = titel; + } + if (körper.beschreibung !== undefined) { + const t = String(körper.beschreibung ?? "").trim(); + if (t.length > TEXT_MAX) fehler.push("Beschreibung ist zu lang."); + else aus.beschreibung = t || null; + } + if (körper.status !== undefined) { + if (!STATUS.includes(körper.status)) fehler.push("Unbekannter Status."); + else aus.status = körper.status; + } + if (körper.prioritaet !== undefined) { + if (!PRIORITAETEN.includes(körper.prioritaet)) fehler.push("Unbekannte Priorität."); + else aus.prioritaet = körper.prioritaet; + } + if (körper.frist !== undefined) { + const f = String(körper.frist ?? "").trim(); + if (!f) aus.frist = null; + else if (!/^\d{4}-\d{2}-\d{2}$/.test(f) || Number.isNaN(Date.parse(f))) fehler.push("Frist ist kein gültiges Datum."); + else aus.frist = f; + } + for (const feld of ["creator_id", "verantwortlich_id"]) { + if (körper[feld] === undefined) continue; + const wert = körper[feld]; + if (wert === null || wert === "") { aus[feld] = null; continue; } + const zahl = Number(wert); + if (!Number.isInteger(zahl) || zahl < 1) fehler.push("Ungültige Zuordnung."); + else aus[feld] = zahl; + } + return { aus, fehler }; +} + +aufgabenRouter.post("/workspace/api/aufgaben", gleicheHerkunft, (req, res) => { + try { + const { aus, fehler } = pruefeFelder(req.body || {}, { neu: true }); + if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); + + /* Wer nicht Management ist, darf ausschließlich für sich selbst + anlegen -- egal, was im Aufruf steht. */ + if (req.person.rolle !== "admin") { + aus.creator_id = req.person.rolle === "creator" ? req.person.id : null; + aus.verantwortlich_id = req.person.id; + } + + for (const feld of ["creator_id", "verantwortlich_id"]) { + if (aus[feld] && !db().prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(aus[feld])) { + return res.status(400).json({ fehler: "Zugeordnete Person gibt es nicht." }); + } + } + + const { lastInsertRowid } = db().prepare(` + INSERT INTO aufgaben + (titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, + frist, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,?,?,?)`).run( + aus.titel, aus.beschreibung ?? null, aus.status ?? "offen", + aus.prioritaet ?? "mittel", aus.creator_id ?? null, aus.verantwortlich_id ?? null, + aus.frist ?? null, jetzt(), req.person.id); + + protokolliere("aufgabe_angelegt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${lastInsertRowid} ${aus.titel}`.slice(0, 120), + }); + res.status(201).json({ id: Number(lastInsertRowid) }); + } catch (fehler) { + console.error("[workspace] Aufgabe anlegen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +aufgabenRouter.patch("/workspace/api/aufgaben/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + /* Erst mit der Sichtbarkeitsregel holen: Was jemand nicht sehen darf, + existiert für ihn auch nicht -- deshalb 404 und nicht 403. Sonst + liesse sich durch Ausprobieren herausfinden, welche Nummern es gibt. */ + const { wo, werte } = sichtbar(req.person); + const aufgabe = db().prepare( + `SELECT a.* ${VERBUND} WHERE ${wo} AND a.id = ?`).get(...werte, id); + if (!aufgabe) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!darfAendern(req.person, aufgabe)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + + const { aus, fehler } = pruefeFelder(req.body || {}, { neu: false }); + if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); + + /* Zuordnungen darf nur das Management verschieben. */ + if (req.person.rolle !== "admin") { delete aus.creator_id; delete aus.verantwortlich_id; } + + const felder = Object.keys(aus); + if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); + + const setz = felder.map((f) => `${f} = ?`); + const daten = felder.map((f) => aus[f]); + setz.push("geaendert = ?"); daten.push(jetzt()); + + if (aus.status === "erledigt" && aufgabe.status !== "erledigt") { + setz.push("erledigt_am = ?"); daten.push(jetzt()); + } else if (aus.status && aus.status !== "erledigt") { + setz.push("erledigt_am = NULL"); + } + + db().prepare(`UPDATE aufgaben SET ${setz.join(", ")} WHERE id = ?`).run(...daten, id); + + protokolliere("aufgabe_geaendert", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} ${felder.join(",")}`.slice(0, 120), + }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Aufgabe ändern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index d70712cd..bbddc0f7 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -58,7 +58,7 @@ const SCRYPT = { N: 32768, r: 8, p: 1, keylen: 64 }; let _db = null; let _dbFehler = null; -function db() { +export function db() { if (_db) return _db; if (_dbFehler) throw _dbFehler; try { @@ -102,6 +102,29 @@ function db() { ip TEXT ); + /* Aufgaben. creator_id sagt, ZU WEM die Aufgabe gehört (wessen + Bereich), verantwortlich_id, WER sie erledigt. Beides getrennt, + weil im Konzept auch Aufgaben vorkommen, die das Management für + einen Creator anlegt. */ + CREATE TABLE IF NOT EXISTS aufgaben ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + titel TEXT NOT NULL, + beschreibung TEXT, + status TEXT NOT NULL DEFAULT 'offen' + CHECK (status IN ('offen','arbeit','review','erledigt')), + prioritaet TEXT NOT NULL DEFAULT 'mittel' + CHECK (prioritaet IN ('hoch','mittel','niedrig')), + creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + verantwortlich_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + frist TEXT, + erstellt TEXT NOT NULL, + erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + geaendert TEXT, + erledigt_am TEXT + ); + CREATE INDEX IF NOT EXISTS idx_aufgaben_status ON aufgaben (status); + CREATE INDEX IF NOT EXISTS idx_aufgaben_creator ON aufgaben (creator_id); + CREATE TABLE IF NOT EXISTS versuche ( ip TEXT NOT NULL, zeitpunkt TEXT NOT NULL @@ -138,7 +161,7 @@ const jetzt = () => new Date().toISOString(); von Cloudflare direkt anspricht, könnte den Kopf frei setzen. Deshalb wird auf die Peer-Adresse zurückgefallen, sobald der Kopf fehlt -- und beide Werte landen im Protokoll, damit so etwas auffällt. */ -function echteIp(req) { +export function echteIp(req) { const cf = req.get("cf-connecting-ip"); if (cf && cf.length <= 45) return cf.trim(); return req.ip || "?"; @@ -161,7 +184,7 @@ function gleich(a, b) { const tokenHash = (t) => createHash("sha256").update(t).digest("hex"); -function protokolliere(aktion, { personId = null, rolle = null, detail = null, ip = null } = {}) { +export function protokolliere(aktion, { personId = null, rolle = null, detail = null, ip = null } = {}) { try { db().prepare( "INSERT INTO protokoll (zeitpunkt, person_id, rolle, aktion, detail, ip) VALUES (?,?,?,?,?,?)" @@ -224,7 +247,7 @@ export const workspaceRouter = express.Router(); /* Schutz der angemeldeten Seiten. Serverseitig, nicht nur im Browser -- sonst könnte man die Seite einfach direkt aufrufen. */ -const GESCHUETZT = ["/workspace/start.html"]; +const GESCHUETZT = ["/workspace/start.html", "/workspace/aufgaben.html"]; workspaceRouter.use((req, res, next) => { if (!GESCHUETZT.includes(req.path)) return next(); diff --git a/workspace/assets/css/aufgaben.css b/workspace/assets/css/aufgaben.css new file mode 100644 index 00000000..8d94994c --- /dev/null +++ b/workspace/assets/css/aufgaben.css @@ -0,0 +1,131 @@ +/* =================================================================== + Aufgabenbrett. Baut auf gate.css und start.css auf. + =================================================================== */ + +.inhalt--breit { max-width: 1240px; } + +.zurueck { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; } +.zurueck:hover { border-bottom-color: currentColor; } + +.kopf-zeile { + display: flex; align-items: flex-end; justify-content: space-between; + gap: 20px; flex-wrap: wrap; margin-bottom: 24px; +} +.kopf-zeile .titel { margin: 0; font-size: clamp(1.4rem, 2.2vw, 1.85rem); } + +.knopf--klein { + width: auto; margin: 0; padding: 10px 18px; font-size: .92rem; +} + +.leise { color: var(--text-still); font-weight: 400; } + +/* ---------- Formular -------------------------------------------------- */ + +.neu { + margin: 0 0 28px; + padding: 20px; + background: rgba(255, 255, 255, .03); + border: 1px solid var(--rand); + border-radius: var(--radius); +} +.neu__zeile { margin-bottom: 14px; } +.neu__raster { + display: grid; gap: 14px; margin-bottom: 8px; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); +} +.neu__knoepfe { display: flex; gap: 10px; align-items: center; margin-top: 12px; } + +.feld-schild { + display: block; margin-bottom: 6px; + font-size: .76rem; font-weight: 600; letter-spacing: .06em; + text-transform: uppercase; color: var(--text-still); +} + +.neu input, .neu textarea, .neu select { + width: 100%; padding: 10px 12px; + background: rgba(255, 255, 255, .045); + border: 1px solid var(--rand); + border-radius: var(--radius-klein); + color: var(--text); font: inherit; + transition: border-color var(--tempo), box-shadow var(--tempo); +} +.neu textarea { resize: vertical; min-height: 62px; } +.neu input:focus, .neu textarea:focus, .neu select:focus { + outline: none; border-color: rgba(63, 189, 245, .7); + box-shadow: 0 0 0 3px rgba(63, 189, 245, .16); +} +/* Der Kalender-Knopf im Datumsfeld ist sonst fast schwarz auf dunkel. */ +.neu input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(.75); cursor: pointer; } + +/* ---------- Brett ----------------------------------------------------- */ + +.brett { + display: grid; gap: 14px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: start; +} + +.spalte { + background: rgba(255, 255, 255, .022); + border: 1px solid var(--rand); + border-radius: var(--radius); + padding: 14px 12px 16px; + min-height: 120px; +} + +.spalte__kopf { + display: flex; align-items: center; gap: 8px; + margin: 0 2px 12px; + font-size: .78rem; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; + color: var(--text-leise); +} +.spalte__punkt { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; } +.spalte__zahl { + margin-left: auto; padding: 1px 8px; + background: rgba(255, 255, 255, .06); border-radius: 999px; + font-size: .74rem; color: var(--text-still); letter-spacing: normal; +} + +.spalte[data-status="offen"] .spalte__punkt { background: #6f89a8; } +.spalte[data-status="arbeit"] .spalte__punkt { background: var(--akzent); } +.spalte[data-status="review"] .spalte__punkt { background: #e0a33c; } +.spalte[data-status="erledigt"] .spalte__punkt { background: #4dab77; } + +.karte { + padding: 12px 13px; + margin-bottom: 9px; + background: rgba(9, 13, 22, .78); + border: 1px solid var(--rand); + border-left: 3px solid transparent; + border-radius: var(--radius-klein); +} +.karte[data-prio="hoch"] { border-left-color: #d8656e; } +.karte[data-prio="mittel"] { border-left-color: #4b6c8a; } +.karte[data-prio="niedrig"] { border-left-color: #3c4a5a; } + +.karte__titel { font-weight: 600; font-size: .93rem; line-height: 1.35; } +.karte__text { margin: 6px 0 0; font-size: .84rem; color: var(--text-leise); } + +.karte__zeile { + display: flex; align-items: center; flex-wrap: wrap; gap: 8px; + margin-top: 10px; font-size: .76rem; color: var(--text-still); +} +.karte__frist--faellig { color: var(--warn); font-weight: 600; } + +.karte__schritte { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; } +.schritt { + padding: 5px 10px; + background: rgba(255, 255, 255, .04); + border: 1px solid var(--rand); + border-radius: 8px; + color: var(--text-leise); font: inherit; font-size: .76rem; cursor: pointer; + transition: background var(--tempo), border-color var(--tempo), color var(--tempo); +} +.schritt:hover { background: rgba(255, 255, 255, .08); color: var(--text); border-color: var(--rand-hell); } +.schritt[disabled] { opacity: .4; cursor: default; } + +.spalte__leer { margin: 4px 2px; font-size: .82rem; color: var(--text-still); } + +@media (max-width: 1080px) { .brett { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 620px) { .brett { grid-template-columns: 1fr; } } diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index 297501ad..c302298c 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -66,6 +66,32 @@ body.start { .willkommen { margin-bottom: 38px; } .willkommen .titel { margin-bottom: 6px; } +/* ---------- Zahlen ---------------------------------------------------- */ + +.zahlen-block { margin-bottom: 34px; } + +.zahlen { + list-style: none; margin: 0; padding: 0; + display: grid; gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(122px, 1fr)); +} + +.zahl { + padding: 14px 16px; + background: rgba(255, 255, 255, .03); + border: 1px solid var(--rand); + border-radius: var(--radius-klein); +} +.zahl__wert { display: block; font-size: 1.7rem; font-weight: 700; line-height: 1.1; } +.zahl__name { display: block; margin-top: 2px; font-size: .78rem; color: var(--text-still); } + +/* Nur wenn wirklich etwas überfällig ist, wird die Kachel hervorgehoben -- + sonst stumpft die Farbe ab und man übersieht sie im Ernstfall. */ +.zahl[data-warn="ja"] { border-color: rgba(255, 154, 162, .42); background: rgba(255, 154, 162, .07); } +.zahl[data-warn="ja"] .zahl__wert { color: var(--warn); } + +/* ---------- Bereiche --------------------------------------------------- */ + .bereiche { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; @@ -93,6 +119,24 @@ body.start { .bereich[data-phase="1"] { border-color: rgba(63, 189, 245, .34); } .bereich[data-phase="1"] .bereich__stand { color: var(--akzent); } +/* Fertige Bereiche sind anklickbar und heben sich deutlich ab. */ +.bereich[data-fertig="ja"] { + padding: 0; + border-color: rgba(63, 189, 245, .5); + background: linear-gradient(100deg, rgba(63, 189, 245, .10), rgba(138, 118, 255, .07)); + transition: border-color var(--tempo), background var(--tempo); +} +.bereich[data-fertig="ja"]:hover { border-color: rgba(63, 189, 245, .8); } + +.bereich__link { + display: flex; align-items: baseline; gap: 10px; width: 100%; + padding: 13px 15px; + color: inherit; text-decoration: none; + border-radius: var(--radius-klein); +} +.bereich__link:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +.bereich[data-fertig="ja"] .bereich__stand { color: var(--akzent); font-weight: 600; } + .inhalt .fuss { margin-top: 40px; text-align: left; } @media (max-width: 520px) { diff --git a/workspace/assets/js/aufgaben.js b/workspace/assets/js/aufgaben.js new file mode 100644 index 00000000..86718d91 --- /dev/null +++ b/workspace/assets/js/aufgaben.js @@ -0,0 +1,215 @@ +/* =================================================================== + Aufgabenbrett. + + Der Server entscheidet, wer was sehen und ändern darf -- diese Datei + zeigt nur an. Was hier ausgeblendet wird, ist Bequemlichkeit, kein + Schutz. + =================================================================== */ +(() => { + 'use strict'; + + const SPALTEN = [ + { status: 'offen', name: 'Offen' }, + { status: 'arbeit', name: 'In Arbeit' }, + { status: 'review', name: 'Review' }, + { status: 'erledigt', name: 'Erledigt' }, + ]; + const WEITER = { offen: 'arbeit', arbeit: 'review', review: 'erledigt' }; + const ZURUECK = { arbeit: 'offen', review: 'arbeit', erledigt: 'review' }; + + const $ = (id) => document.getElementById(id); + const heute = new Date().toISOString().slice(0, 10); + let ich = null; + + /* Texte werden ausschließlich über textContent gesetzt, nie über + innerHTML -- ein Aufgabentitel darf keine Auszeichnung einschleusen. */ + 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 = (text) => { $('fehler').textContent = text || ''; }; + + async function hole(pfad, optionen = {}) { + const antwort = await fetch(pfad, { credentials: 'same-origin', ...optionen }); + if (antwort.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + return antwort; + } + + /* ---------- Brett zeichnen ------------------------------------------ */ + + function datumHuebsch(iso) { + const [j, m, t] = iso.split('-'); + return `${t}.${m}.${j.slice(2)}`; + } + + function karte(a) { + const k = el('article', 'karte'); + k.dataset.prio = a.prioritaet; + k.append(el('h3', 'karte__titel', a.titel)); + if (a.beschreibung) k.append(el('p', 'karte__text', a.beschreibung)); + + const zeile = el('div', 'karte__zeile'); + if (a.frist) { + const faellig = a.status !== 'erledigt' && a.frist < heute; + const f = el('span', 'karte__frist' + (faellig ? ' karte__frist--faellig' : ''), + (faellig ? 'überfällig ' : 'bis ') + datumHuebsch(a.frist)); + zeile.append(f); + } + if (a.verantwortlich_name) zeile.append(el('span', null, '· ' + a.verantwortlich_name)); + if (ich.rolle === 'admin' && a.creator_name && a.creator_name !== a.verantwortlich_name) { + zeile.append(el('span', null, '· Bereich ' + a.creator_name)); + } + if (zeile.childElementCount) k.append(zeile); + + const schritte = el('div', 'karte__schritte'); + if (ZURUECK[a.status]) schritte.append(knopf('◀ zurück', a, ZURUECK[a.status])); + if (WEITER[a.status]) schritte.append(knopf(beschriftung(a.status), a, WEITER[a.status])); + if (schritte.childElementCount) k.append(schritte); + return k; + } + + const beschriftung = (status) => ({ + offen: 'starten ▶', arbeit: 'zum Review ▶', review: 'erledigt ▶', + })[status]; + + function knopf(text, aufgabe, neuerStatus) { + const b = el('button', 'schritt', text); + b.type = 'button'; + b.addEventListener('click', async () => { + b.disabled = true; + melde(''); + try { + const antwort = await hole(`/workspace/api/aufgaben/${aufgabe.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: neuerStatus }), + }); + if (!antwort.ok) { + const d = await antwort.json().catch(() => ({})); + melde(d.fehler === 'nicht_erlaubt' + ? 'Diese Aufgabe darfst du nicht ändern.' + : 'Ändern hat nicht geklappt.'); + b.disabled = false; + return; + } + await laden(); + } catch { b.disabled = false; } + }); + return b; + } + + function zeichne(aufgaben) { + const brett = $('brett'); + brett.textContent = ''; + brett.setAttribute('aria-busy', 'false'); + + for (const s of SPALTEN) { + const eigene = aufgaben.filter((a) => a.status === s.status); + const spalte = el('section', 'spalte'); + spalte.dataset.status = s.status; + + const kopf = el('h2', 'spalte__kopf'); + kopf.append(el('span', 'spalte__punkt'), el('span', null, s.name), + el('span', 'spalte__zahl', String(eigene.length))); + spalte.append(kopf); + + if (!eigene.length) spalte.append(el('p', 'spalte__leer', '—')); + else for (const a of eigene) spalte.append(karte(a)); + + brett.append(spalte); + } + } + + async function laden() { + try { + const antwort = await hole('/workspace/api/aufgaben'); + if (!antwort.ok) { melde('Aufgaben konnten nicht geladen werden.'); return; } + zeichne((await antwort.json()).aufgaben); + } catch { /* bereits umgeleitet */ } + } + + /* ---------- Neue Aufgabe -------------------------------------------- */ + + function formularUmschalten(auf) { + $('neu').hidden = !auf; + $('neu-oeffnen').hidden = auf; + if (auf) $('f-titel').focus(); + } + + $('neu-oeffnen').addEventListener('click', () => formularUmschalten(true)); + $('neu-abbrechen').addEventListener('click', () => { + $('neu').reset(); $('neu-fehler').textContent = ''; formularUmschalten(false); + }); + + $('neu').addEventListener('submit', async (e) => { + e.preventDefault(); + const knopfEl = $('neu-speichern'); + $('neu-fehler').textContent = ''; + + const daten = { + titel: $('f-titel').value, + beschreibung: $('f-text').value, + prioritaet: $('f-prio').value, + frist: $('f-frist').value, + }; + if (ich.rolle === 'admin') { + daten.creator_id = $('f-creator').value || null; + daten.verantwortlich_id = $('f-verant').value || null; + } + + knopfEl.disabled = true; + try { + const antwort = await hole('/workspace/api/aufgaben', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(daten), + }); + if (!antwort.ok) { + const d = await antwort.json().catch(() => ({})); + $('neu-fehler').textContent = d.fehler || 'Anlegen hat nicht geklappt.'; + return; + } + $('neu').reset(); + formularUmschalten(false); + await laden(); + } catch { /* bereits umgeleitet */ } finally { knopfEl.disabled = false; } + }); + + /* ---------- Start ----------------------------------------------------- */ + + $('abmelden').addEventListener('click', async () => { + try { await fetch('/workspace/api/abmelden', { method: 'POST', credentials: 'same-origin' }); } + catch { /* egal */ } + location.assign('/workspace/'); + }); + + (async () => { + try { + const antwort = await hole('/workspace/api/ich'); + if (!antwort.ok) { location.assign('/workspace/'); return; } + ich = await antwort.json(); + } catch { return; } + + $('wer').textContent = `${ich.name} · ${ich.rolle}`; + + if (ich.rolle === 'admin') { + $('feld-creator').hidden = false; + $('feld-verant').hidden = false; + try { + const { personen } = await (await hole('/workspace/api/personen')).json(); + for (const p of personen) { + for (const ziel of ['f-creator', 'f-verant']) { + const o = el('option', null, `${p.name} (${p.rolle})`); + o.value = String(p.id); + $(ziel).append(o); + } + } + } catch { /* Zuordnung bleibt dann leer -- kein Grund abzubrechen */ } + } + + await laden(); + })(); +})(); diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index 67880673..f1701218 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -19,7 +19,7 @@ { name: 'Dashboard', phase: 1, rollen: ['admin', 'creator', 'scout'] }, { name: 'Creator-Profile', phase: 1, rollen: ['admin'] }, { name: 'Mein Profil', phase: 1, rollen: ['creator'] }, - { name: 'Aufgaben', phase: 1, rollen: ['admin', 'creator'] }, + { name: 'Aufgaben', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'aufgaben.html' }, { name: 'Kalender', phase: 1, rollen: ['admin', 'creator', 'scout'] }, { name: 'Dateien', phase: 1, rollen: ['admin', 'creator'] }, { name: 'LIVE-Analyse', phase: 2, rollen: ['admin', 'creator'] }, @@ -55,6 +55,7 @@ const li = document.createElement('li'); li.className = 'bereich'; li.dataset.phase = String(b.phase); + if (b.ziel) li.dataset.fertig = 'ja'; const name = document.createElement('span'); name.className = 'bereich__name'; @@ -62,9 +63,61 @@ const stand = document.createElement('span'); stand.className = 'bereich__stand'; - stand.textContent = b.phase === 1 ? 'als Nächstes' : `Phase ${b.phase}`; + stand.textContent = b.ziel ? 'öffnen' : (b.phase === 1 ? 'als Nächstes' : `Phase ${b.phase}`); - li.append(name, stand); + if (b.ziel) { + /* Fertige Bereiche sind echte Verweise -- Tastatur und + Rechtsklick funktionieren damit wie gewohnt. */ + const a = document.createElement('a'); + a.className = 'bereich__link'; + a.href = b.ziel; + a.append(name, stand); + li.append(a); + } else { + li.append(name, stand); + } + liste.append(li); + } + + await zahlenLaden(); + } + + /* Die Zahlen kommen aus derselben Quelle wie das Brett und sind bereits + serverseitig auf die eigene Rolle eingegrenzt. */ + const ZAHLEN = [ + { schluessel: 'ueberfaellig', name: 'Überfällig', warn: true }, + { schluessel: 'heute', name: 'Heute fällig' }, + { schluessel: 'offen', name: 'Offen' }, + { schluessel: 'arbeit', name: 'In Arbeit' }, + { schluessel: 'review', name: 'Review' }, + { schluessel: 'erledigt', name: 'Erledigt' }, + ]; + + async function zahlenLaden() { + let daten; + try { + const antwort = await fetch('/workspace/api/uebersicht', { credentials: 'same-origin' }); + if (!antwort.ok) return; + daten = await antwort.json(); + } catch { return; } + + const liste = $('zahlen'); + liste.textContent = ''; + for (const z of ZAHLEN) { + const wert = daten[z.schluessel] ?? 0; + const li = document.createElement('li'); + li.className = 'zahl'; + if (z.warn && wert > 0) li.dataset.warn = 'ja'; + + const n = document.createElement('span'); + n.className = 'zahl__wert'; + n.textContent = String(wert); + + const t = document.createElement('span'); + t.className = 'zahl__name'; + t.textContent = z.name; + + li.append(n, t); liste.append(li); } } diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html new file mode 100644 index 00000000..70894e55 --- /dev/null +++ b/workspace/aufgaben.html @@ -0,0 +1,86 @@ + + +
+ + +Creator Workspace · Aufgaben
+Aufgaben
+Aufgaben werden geladen …
+…
- + +Deine Aufgaben
+Bereiche aus dem Konzept
+Bereiche