Gespräche
+Calls & Protokolle
++ Jeder Call endet mit klaren To-dos – die landen direkt im Aufgabenbrett, + nicht in einem Dokument, das niemand mehr öffnet. +
+Gespräche werden geladen …
diff --git a/server/index.js b/server/index.js index abcacb09..849d2df0 100644 --- a/server/index.js +++ b/server/index.js @@ -22,6 +22,7 @@ import { dateienRouter } from "./workspace-dateien.js"; import { bereicheRouter } from "./workspace-bereiche.js"; import { reportRouter } from "./workspace-reports.js"; import { scoutRouter } from "./workspace-scouts.js"; +import { callRouter } from "./workspace-calls.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -84,6 +85,7 @@ app.use(dateienRouter); app.use(bereicheRouter); app.use(reportRouter); app.use(scoutRouter); +app.use(callRouter); /* 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-calls.js b/server/workspace-calls.js new file mode 100644 index 00000000..c45de467 --- /dev/null +++ b/server/workspace-calls.js @@ -0,0 +1,241 @@ +/* ===================================================================== + workspace-calls.js — Calls & Meeting-Protokolle (Konzept, Seite 13). + + Calls sind KEINE eigene Terminart neben dem Kalender, sondern + dieselben Termine mit art = 'call'. Ein zweiter Terminspeicher waere + die sichere Art, irgendwann zwei widerspruechliche Uhrzeiten zu haben. + Diese Datei fuegt nur das hinzu, was ein Gespraech vom blossen Termin + unterscheidet: das Protokoll danach. + + Der tragende Satz aus dem Deck: + "Jeder Call endet mit klaren To-dos, die direkt ins Board uebernommen + werden." + + Woertlich genommen heisst das: To-dos werden nicht als Text im + Protokoll abgelegt, sondern sofort zu echten Aufgaben. Sonst steht die + Verabredung in einem Dokument, das niemand mehr oeffnet. + ===================================================================== */ + +import express from "express"; +import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js"; + +export const callRouter = express.Router(); + +const TITEL_MAX = 160; +const TEXT_MAX = 6000; +const TODOS_MAX = 12; + +const jetzt = () => new Date().toISOString(); + +/* Lokale Zeit im selben Format wie ein datetime-local-Feld -- damit ist + "vergangen" derselbe Vergleich wie im Kalender. */ +function jetztLokal() { + const d = new Date(); + const p = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; +} + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + req.person = person; + next(); +} + +function gleicheHerkunft(req, res, next) { + const herkunft = req.get("origin"); + if (!herkunft) return next(); + let erlaubt; + try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } + if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); + next(); +} + +callRouter.use("/workspace/api/calls", angemeldet); + +/* Dieselbe Sichtbarkeitsregel wie im Kalender -- absichtlich Wort fuer + Wort, damit ein Termin nicht in der einen Ansicht auftaucht und in der + anderen fehlt. */ +function sichtbar(person) { + if (person.rolle === "admin") return { wo: "1=1", werte: [] }; + return { + wo: "(t.creator_id = ? OR t.teilnehmer_id = ? OR t.erstellt_von = ?)", + werte: [person.id, person.id, person.id], + }; +} + +const SPALTEN = ` + t.id, t.titel, t.beschreibung, t.art, t.beginn, t.dauer_min, t.ort, + t.creator_id, t.teilnehmer_id, t.erledigt, + pc.name AS creator_name, pt.name AS teilnehmer_name, + pr.id AS protokoll_id, pr.punkte, pr.entscheidungen, + pr.erstellt AS protokoll_erstellt, pr.naechster_termin_id, + pe.name AS protokoll_von`; + +const VERBUND = ` + FROM termine t + LEFT JOIN personen pc ON pc.id = t.creator_id + LEFT JOIN personen pt ON pt.id = t.teilnehmer_id + LEFT JOIN protokolle pr ON pr.termin_id = t.id + LEFT JOIN personen pe ON pe.id = pr.erstellt_von`; + +/* Aufgaben, die aus einem Gespraech entstanden sind. */ +function aufgabenZu(protokollIds) { + if (!protokollIds.length) return new Map(); + const platz = protokollIds.map(() => "?").join(","); + const zeilen = db().prepare(` + SELECT pa.protokoll_id, a.id, a.titel, a.status, a.frist, p.name AS verantwortlich_name + FROM protokoll_aufgaben pa + JOIN aufgaben a ON a.id = pa.aufgabe_id + LEFT JOIN personen p ON p.id = a.verantwortlich_id + WHERE pa.protokoll_id IN (${platz}) + ORDER BY a.id`).all(...protokollIds); + const karte = new Map(); + for (const z of zeilen) { + if (!karte.has(z.protokoll_id)) karte.set(z.protokoll_id, []); + karte.get(z.protokoll_id).push({ + id: z.id, titel: z.titel, status: z.status, + frist: z.frist, verantwortlich_name: z.verantwortlich_name, + }); + } + return karte; +} + +/* ---------- Liste ------------------------------------------------------- */ + +callRouter.get("/workspace/api/calls", (req, res) => { + try { + const { wo, werte } = sichtbar(req.person); + const alle = db().prepare(` + SELECT ${SPALTEN} ${VERBUND} + WHERE ${wo} AND t.art IN ('call','review') + ORDER BY t.beginn DESC`).all(...werte); + + const karte = aufgabenZu(alle.filter((c) => c.protokoll_id).map((c) => c.protokoll_id)); + for (const c of alle) c.aufgaben = karte.get(c.protokoll_id) || []; + + const grenze = jetztLokal(); + res.json({ + jetzt: grenze, + /* Die Reihenfolge der Gruppen ist die Dringlichkeit: Was fehlt, + steht oben -- nicht was als Naechstes kommt. */ + offen: alle.filter((c) => c.beginn <= grenze && !c.protokoll_id).reverse(), + anstehend: alle.filter((c) => c.beginn > grenze).reverse(), + erledigt: alle.filter((c) => c.beginn <= grenze && c.protokoll_id), + }); + } catch (fehler) { + console.error("[workspace] Calls lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Protokoll schreiben ----------------------------------------- */ + +callRouter.post("/workspace/api/calls/:id/protokoll", gleicheHerkunft, (req, res) => { + const d = db(); + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const { wo, werte } = sichtbar(req.person); + const termin = d.prepare(` + SELECT t.id, t.titel, t.beginn, t.dauer_min, t.ort, t.art, + t.creator_id, t.teilnehmer_id, pr.id AS protokoll_id + FROM termine t LEFT JOIN protokolle pr ON pr.termin_id = t.id + WHERE ${wo} AND t.id = ? AND t.art IN ('call','review')`).get(...werte, id); + if (!termin) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const k = req.body || {}; + const punkte = String(k.punkte ?? "").trim().slice(0, TEXT_MAX) || null; + const entscheidungen = String(k.entscheidungen ?? "").trim().slice(0, TEXT_MAX) || null; + + /* To-dos pruefen, BEVOR irgendetwas geschrieben wird. */ + const roh = Array.isArray(k.todos) ? k.todos : []; + if (roh.length > TODOS_MAX) { + return res.status(400).json({ fehler: `Höchstens ${TODOS_MAX} To-dos auf einmal.` }); + } + const todos = []; + for (const t of roh) { + const titel = String(t?.titel ?? "").trim(); + if (!titel) continue; + if (titel.length > TITEL_MAX) return res.status(400).json({ fehler: "Ein To-do ist zu lang." }); + const frist = String(t?.frist ?? "").trim(); + if (frist && (!/^\d{4}-\d{2}-\d{2}$/.test(frist) || Number.isNaN(Date.parse(frist)))) { + return res.status(400).json({ fehler: "Frist eines To-dos ist ungültig." }); + } + todos.push({ titel, frist: frist || null }); + } + + /* Nächster Termin, falls im Protokoll vereinbart. */ + const nb = String(k.naechster_beginn ?? "").trim(); + if (nb && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(nb)) { + return res.status(400).json({ fehler: "Der nächste Termin hat kein gültiges Datum." }); + } + let dauer = Number(k.naechster_dauer ?? termin.dauer_min ?? 30); + if (!Number.isInteger(dauer) || dauer < 5 || dauer > 480) dauer = 30; + + if (!punkte && !entscheidungen && !todos.length) { + return res.status(400).json({ fehler: "Ein leeres Protokoll hilft niemandem." }); + } + + /* Alles zusammen oder gar nicht -- sonst gaebe es Aufgaben ohne + Protokoll oder umgekehrt. */ + let antwort; + d.exec("BEGIN"); + try { + let protokollId = termin.protokoll_id; + if (protokollId) { + d.prepare("UPDATE protokolle SET punkte = ?, entscheidungen = ?, geaendert = ? WHERE id = ?") + .run(punkte, entscheidungen, jetzt(), protokollId); + } else { + protokollId = Number(d.prepare(` + INSERT INTO protokolle (termin_id, punkte, entscheidungen, erstellt, erstellt_von) + VALUES (?,?,?,?,?)`).run(id, punkte, entscheidungen, jetzt(), req.person.id).lastInsertRowid); + } + + /* To-dos werden zu echten Aufgaben -- genau das ist der Punkt. */ + const neueAufgaben = []; + for (const t of todos) { + const a = Number(d.prepare(` + INSERT INTO aufgaben + (titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, frist, erstellt, erstellt_von) + VALUES (?,?,'offen','mittel',?,?,?,?,?)`).run( + t.titel, `Aus dem Gespräch „${termin.titel}"`, + termin.creator_id, termin.teilnehmer_id ?? termin.creator_id, + t.frist, jetzt(), req.person.id).lastInsertRowid); + d.prepare("INSERT INTO protokoll_aufgaben (protokoll_id, aufgabe_id) VALUES (?,?)") + .run(protokollId, a); + neueAufgaben.push(a); + } + + /* Nächster Termin als echter Kalendereintrag, mit demselben + Gegenüber und demselben Meeting-Link. */ + let naechster = null; + if (nb) { + naechster = Number(d.prepare(` + INSERT INTO termine + (titel, beschreibung, art, beginn, dauer_min, ort, creator_id, teilnehmer_id, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,?,?,?,?)`).run( + termin.titel, `Folgetermin zu „${termin.titel}"`, termin.art, nb, dauer, + termin.ort, termin.creator_id, termin.teilnehmer_id, jetzt(), req.person.id).lastInsertRowid); + d.prepare("UPDATE protokolle SET naechster_termin_id = ? WHERE id = ?").run(naechster, protokollId); + } + + d.prepare("UPDATE termine SET erledigt = 1 WHERE id = ?").run(id); + d.exec("COMMIT"); + antwort = { protokoll_id: protokollId, aufgaben: neueAufgaben.length, naechster_termin_id: naechster }; + } catch (fehler) { + d.exec("ROLLBACK"); + throw fehler; + } + + protokolliere("protokoll_geschrieben", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Termin #${id}, ${antwort.aufgaben} To-do(s)`.slice(0, 120), + }); + res.status(201).json(antwort); + } catch (fehler) { + console.error("[workspace] Protokoll schreiben:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index cdd13d05..9806d687 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -222,6 +222,32 @@ export function db() { ); CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id); + /* Meeting-Protokoll zu einem Termin (Konzept, Seite 13). + Bewusst eine eigene Tabelle statt neuer Spalten in termine: Es + gibt hier keine Migrationen, und ein Protokoll gehoert ohnehin + nur zu einem Bruchteil der Termine. Ein Termin hat hoechstens + ein Protokoll -- daher UNIQUE. */ + CREATE TABLE IF NOT EXISTS protokolle ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + termin_id INTEGER NOT NULL UNIQUE REFERENCES termine(id) ON DELETE CASCADE, + punkte TEXT, + entscheidungen TEXT, + naechster_termin_id INTEGER REFERENCES termine(id) ON DELETE SET NULL, + erstellt TEXT NOT NULL, + erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + geaendert TEXT + ); + + /* "Jeder Call endet mit klaren To-dos, die direkt ins Board + uebernommen werden." Die To-dos leben deshalb NICHT im Protokoll, + sondern sind echte Aufgaben. Diese Tabelle merkt sich nur, aus + welchem Gespraech eine Aufgabe entstanden ist. */ + CREATE TABLE IF NOT EXISTS protokoll_aufgaben ( + protokoll_id INTEGER NOT NULL REFERENCES protokolle(id) ON DELETE CASCADE, + aufgabe_id INTEGER NOT NULL REFERENCES aufgaben(id) ON DELETE CASCADE, + PRIMARY KEY (protokoll_id, aufgabe_id) + ); + /* Scout-CRM: die Pipeline vom ersten Fund bis zur Uebergabe. creator_id ist gesetzt, sobald aus einem uebergebenen Lead eine echte Person angelegt wurde -- damit bleibt nachvollziehbar, wer @@ -387,6 +413,7 @@ const GESCHUETZT = { "/workspace/bereich.html": ["admin", "creator"], "/workspace/report.html": ["admin", "creator"], "/workspace/scouting.html": ["admin", "scout"], + "/workspace/calls.html": null, }; workspaceRouter.use((req, res, next) => { diff --git a/workspace/assets/css/calls.css b/workspace/assets/css/calls.css new file mode 100644 index 00000000..51786b8d --- /dev/null +++ b/workspace/assets/css/calls.css @@ -0,0 +1,140 @@ +/* =================================================================== + Calls & Meeting-Protokolle. + + Nur eine Gruppe wird optisch hervorgehoben: die, in der etwas fehlt. + Alles andere bleibt bewusst ruhig -- sonst schreit die ganze Seite + und man sieht nicht mehr, was wirklich dran ist. + =================================================================== */ + +.gruppe { margin-bottom: 32px; } +.stufe__kopf { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; } +.stufe__titel { + margin: 0; font-size: .78rem; font-weight: 700; + letter-spacing: .08em; text-transform: uppercase; color: var(--akzent); +} +.stufe__zahl { + min-width: 22px; padding: 1px 7px; text-align: center; + font-size: .74rem; font-weight: 700; color: var(--text-still); + background: rgba(255, 255, 255, .06); border-radius: 999px; +} +.block__frage { margin: 0 0 12px; font-size: .85rem; color: var(--text-still); } +.gruppe__karten { display: grid; gap: 10px; margin-top: 12px; } + +.gruppe[data-gruppe="offen"] .stufe__titel { color: #f0c48a; } +.gruppe[data-gruppe="offen"] .stufe__zahl { + color: #f0c48a; background: rgba(240, 196, 138, .13); +} + +/* ---------- Karte --------------------------------------------------------- */ + +.call { + position: relative; + padding: 15px 18px 15px 21px; + background: rgba(255, 255, 255, .03); + border: 1px solid var(--rand); + border-radius: var(--radius-klein); + transition: border-color .18s ease; +} +.call::before { + content: ""; position: absolute; left: 0; top: 10px; bottom: 10px; width: 3px; + border-radius: 0 3px 3px 0; + background: var(--rand-stark, rgba(255, 255, 255, .18)); +} +.call[data-gruppe="offen"] { + border-color: rgba(240, 196, 138, .32); + background: rgba(240, 196, 138, .045); +} +.call[data-gruppe="offen"]::before { background: #f0c48a; } +.call[data-gruppe="anstehend"]::before { background: var(--akzent); } +.call[data-gruppe="erledigt"] { opacity: .82; } +.call[data-gruppe="erledigt"]:hover { opacity: 1; } +.call[data-gruppe="erledigt"]::before { background: #79d1a2; opacity: .55; } +.call:hover { border-color: rgba(255, 255, 255, .18); } + +.call__kopf { display: flex; gap: 14px; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; } +.call__titel { margin: 0; font-size: 1.02rem; font-weight: 650; } +.call__zeit { margin: 2px 0 0; font-size: .82rem; color: var(--text-still); } +.call__text, .call__ort { margin: 10px 0 0; font-size: .86rem; color: var(--text-leise); } +.call__ort::before { content: "Ort: "; color: var(--text-still); } + +.marke-fehlt { + padding: 3px 9px; border-radius: 999px; + font-size: .71rem; font-weight: 600; + color: #f0c48a; background: rgba(240, 196, 138, .12); + border: 1px solid rgba(240, 196, 138, .35); +} + +/* ---------- Aufgeklappt ---------------------------------------------------- */ + +.call__detail { margin-top: 15px; padding-top: 15px; border-top: 1px solid var(--rand); } +.call__abschnitt { + margin: 2px 0 14px; font-size: .88rem; color: var(--text-leise); + white-space: pre-wrap; max-width: 78ch; +} +.call__quelle { margin: 12px 0 0; font-size: .77rem; color: var(--text-still); } +.call__verweis { color: var(--akzent); } + +.todo-liste { margin: 4px 0 0; padding: 0; list-style: none; } +.todo-liste li { + padding: 9px 13px; margin-bottom: 6px; + background: rgba(255, 255, 255, .025); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + font-size: .87rem; +} +.todo-liste li[data-status="erledigt"] { opacity: .6; } +.todo-liste li[data-status="erledigt"] .todo-liste__titel { text-decoration: line-through; } +.todo-liste__titel { display: block; } +.todo-liste .kk__still { display: block; margin-top: 2px; font-size: .76rem; color: var(--text-still); } + +/* ---------- Protokollbogen -------------------------------------------------- */ + +.bogen__hinweis { margin: 1px 0 6px; font-size: .78rem; color: var(--text-still); max-width: 70ch; } +.bogen__feld { + width: 100%; margin-bottom: 16px; padding: 10px 12px; + background: rgba(255, 255, 255, .04); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; resize: vertical; +} +.bogen__zeit { + margin-bottom: 14px; padding: 9px 11px; + background: rgba(255, 255, 255, .05); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; +} + +.todos { display: grid; gap: 7px; margin: 4px 0 10px; } +.todo { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 7px; } +.todo__titel, .todo__frist { + padding: 9px 11px; + background: rgba(255, 255, 255, .05); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; +} +.todo__weg { + width: 34px; cursor: pointer; + background: transparent; border: 1px solid var(--rand); + border-radius: var(--radius-klein); + color: var(--text-still); font-size: 1.05rem; line-height: 1; + transition: color .16s ease, border-color .16s ease; +} +.todo__weg:hover { color: #ffb4b9; border-color: rgba(255, 154, 162, .45); } + +.bogen__feld:focus, .bogen__zeit:focus, .todo__titel:focus, .todo__frist:focus { + outline: none; border-color: rgba(63, 189, 245, .65); + box-shadow: 0 0 0 3px rgba(63, 189, 245, .14); +} + +.leer-hinweis { + padding: 30px; text-align: center; color: var(--text-still); font-size: .9rem; + border: 1px dashed var(--rand); border-radius: var(--radius); +} + +@media (max-width: 560px) { + .todo { grid-template-columns: minmax(0, 1fr) auto; } + .todo__frist { grid-column: 1 / 2; } + .todo__weg { grid-row: 1 / 3; } +} + +@media (prefers-reduced-motion: reduce) { + .call, .todo__weg { transition: none; } +} diff --git a/workspace/assets/css/scouting.css b/workspace/assets/css/scouting.css index 7d94711b..c4ed5d4b 100644 --- a/workspace/assets/css/scouting.css +++ b/workspace/assets/css/scouting.css @@ -121,20 +121,6 @@ color: #fff; } -/* Zurueckhaltender Zweitknopf -- gab es im Baukasten noch nicht. */ -.knopf-still { - padding: 7px 13px; cursor: pointer; - background: transparent; - border: 1px solid var(--rand); border-radius: var(--radius-klein); - color: var(--text-still); font: inherit; font-size: .82rem; - transition: color .16s ease, border-color .16s ease, background .16s ease; -} -.knopf-still:hover { - color: var(--text); - border-color: rgba(255, 255, 255, .22); - background: rgba(255, 255, 255, .04); -} - /* Das Onboarding ist der einzige Schritt, der eine echte Person anlegt -- der Knopf soll sich deshalb von den anderen abheben. */ .knopf--gold { diff --git a/workspace/assets/css/start.css b/workspace/assets/css/start.css index c6932afc..0a01e081 100644 --- a/workspace/assets/css/start.css +++ b/workspace/assets/css/start.css @@ -212,3 +212,21 @@ body.start { .kopfleiste .marke { font-size: .64rem; } .wer { max-width: 34vw; } } + +/* Zurueckhaltender Zweitknopf. Gehoert bewusst hierher und nicht in eine + einzelne Seite: Er wird ueberall gebraucht, und als Seiten-CSS fehlte er + prompt dort, wo die Datei nicht geladen war -- dann standen nackte + Systemknoepfe in der Oberflaeche. */ +.knopf-still { + padding: 7px 13px; cursor: pointer; + background: transparent; + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text-still); font: inherit; font-size: .82rem; + transition: color .16s ease, border-color .16s ease, background .16s ease; +} +.knopf-still:hover { + color: var(--text); + border-color: rgba(255, 255, 255, .22); + background: rgba(255, 255, 255, .04); +} +@media (prefers-reduced-motion: reduce) { .knopf-still { transition: none; } } diff --git a/workspace/assets/js/calls.js b/workspace/assets/js/calls.js new file mode 100644 index 00000000..1eb398a0 --- /dev/null +++ b/workspace/assets/js/calls.js @@ -0,0 +1,334 @@ +/* =================================================================== + Calls & Meeting-Protokolle (Konzept, Seite 13). + + Reihenfolge der Gruppen ist bewusst nicht chronologisch, sondern nach + Dringlichkeit: Zuerst die Gespraeche, deren Protokoll FEHLT. Ein + vergangener Call ohne Protokoll ist der einzige Zustand, der etwas + von einem verlangt. + =================================================================== */ +(() => { + 'use strict'; + + const $ = (id) => document.getElementById(id); + let ich = null; + let daten = null; + let offen = new Set(); + + 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 WOCHENTAG = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; + function zeitpunkt(iso) { + if (!iso) return ''; + const [d, z] = iso.split('T'); + const [j, m, t] = d.split('-'); + const w = WOCHENTAG[new Date(+j, +m - 1, +t).getDay()]; + return `${w}, ${t}.${m}.${j} · ${z} Uhr`; + } + const datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : ''); + + /* Ein Meeting-Link ist nur dann ein Link, wenn er auch einer ist -- + alles andere bleibt schlichter Text (z. B. "bei mir zu Hause"). */ + function istLink(ort) { + if (!ort) return false; + try { + const u = new URL(ort.trim()); + return u.protocol === 'https:' || u.protocol === 'http:'; + } catch { return false; } + } + + async function hole(pfad, optionen = {}) { + const a = await fetch(pfad, { credentials: 'same-origin', ...optionen }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + return a; + } + + /* ---------- Protokollbogen ---------------------------------------------- */ + + function bogen(c) { + const f = el('form', 'bogen'); + + const feld = (schild, hinweis, wert) => { + f.append(el('label', 'feld-schild', schild)); + if (hinweis) f.append(el('p', 'bogen__hinweis', hinweis)); + const t = el('textarea', 'bogen__feld'); + t.rows = 3; + t.maxLength = 6000; + t.value = wert || ''; + f.append(t); + return t; + }; + + const fPunkte = feld('Besprochene Punkte', null, c.punkte); + const fEnt = feld('Entscheidungen', + 'Was wurde festgelegt? Nicht was diskutiert wurde – was gilt.', c.entscheidungen); + + /* To-dos: der Teil, der laut Konzept direkt ins Board wandert. */ + f.append(el('label', 'feld-schild', 'To-dos')); + f.append(el('p', 'bogen__hinweis', + 'Jede Zeile wird sofort eine echte Aufgabe im Brett – mit dem Gespräch als Herkunft.')); + const todoBox = el('div', 'todos'); + f.append(todoBox); + + const todoZeile = (fokus) => { + const z = el('div', 'todo'); + const t = el('input', 'todo__titel'); + t.type = 'text'; t.maxLength = 160; + t.placeholder = 'Was ist zu tun?'; + const d = el('input', 'todo__frist'); + d.type = 'date'; d.title = 'Frist (optional)'; + const weg = el('button', 'todo__weg', '×'); + weg.type = 'button'; + weg.title = 'Zeile entfernen'; + weg.setAttribute('aria-label', 'Zeile entfernen'); + weg.addEventListener('click', () => { + z.remove(); + if (!todoBox.children.length) todoZeile(true); + }); + /* Enter in der letzten Zeile legt die naechste an -- so kann man + eine Liste tippen, ohne zur Maus zu greifen. */ + t.addEventListener('keydown', (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + if (z === todoBox.lastElementChild && t.value.trim()) todoZeile(true); + else z.nextElementSibling?.querySelector('.todo__titel')?.focus(); + }); + z.append(t, d, weg); + todoBox.append(z); + if (fokus) t.focus(); + return z; + }; + todoZeile(false); + + const mehr = el('button', 'knopf-still', '+ Zeile'); + mehr.type = 'button'; + mehr.addEventListener('click', () => todoZeile(true)); + f.append(mehr); + + /* Nächster Termin */ + f.append(el('label', 'feld-schild', 'Nächster Termin')); + f.append(el('p', 'bogen__hinweis', + 'Wenn hier etwas steht, wird der Folgetermin gleich in den Kalender gelegt – ' + + 'mit demselben Gegenüber und demselben Link.')); + const naechster = el('input', 'bogen__zeit'); + naechster.type = 'datetime-local'; + f.append(naechster); + + const meldung = el('p', 'fehler'); + meldung.setAttribute('role', 'alert'); + f.append(meldung); + + const knöpfe = el('div', 'kk__knoepfe'); + const speichern = el('button', 'knopf knopf--klein', 'Protokoll festhalten'); + speichern.type = 'submit'; + const abbrechen = el('button', 'knopf-still', 'Abbrechen'); + abbrechen.type = 'button'; + abbrechen.addEventListener('click', () => { offen.delete(c.id); zeichne(); }); + knöpfe.append(speichern, abbrechen); + f.append(knöpfe); + + f.addEventListener('submit', async (e) => { + e.preventDefault(); + meldung.textContent = ''; + const todos = [...todoBox.querySelectorAll('.todo')] + .map((z) => ({ + titel: z.querySelector('.todo__titel').value, + frist: z.querySelector('.todo__frist').value, + })) + .filter((t) => t.titel.trim()); + + if (!fPunkte.value.trim() && !fEnt.value.trim() && !todos.length) { + meldung.textContent = 'Ein leeres Protokoll hilft niemandem.'; + return; + } + speichern.disabled = true; + try { + const a = await hole(`/workspace/api/calls/${c.id}/protokoll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + punkte: fPunkte.value, entscheidungen: fEnt.value, todos, + naechster_beginn: naechster.value, + }), + }); + if (!a.ok) { + meldung.textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; + return; + } + const r = await a.json(); + offen.delete(c.id); + await laden(); + melde(r.aufgaben + ? `Protokoll festgehalten – ${r.aufgaben} To-do(s) liegen jetzt im Aufgabenbrett.` + : 'Protokoll festgehalten.'); + setTimeout(() => melde(''), 5000); + } catch { /* umgeleitet */ } finally { speichern.disabled = false; } + }); + + return f; + } + + /* ---------- Karte -------------------------------------------------------- */ + + function karte(c, gruppe) { + const k = el('article', 'call'); + k.dataset.gruppe = gruppe; + k.id = 'call-' + c.id; + + const kopf = el('div', 'call__kopf'); + const links = el('div'); + links.append(el('h3', 'call__titel', c.titel)); + const wer = [c.creator_name, c.teilnehmer_name].filter(Boolean).join(' · '); + links.append(el('p', 'call__zeit', + zeitpunkt(c.beginn) + ` · ${c.dauer_min} Min` + (wer ? ' · ' + wer : ''))); + kopf.append(links); + + const marken = el('div', 'kk__marken'); + if (c.art === 'review') marken.append(el('span', 'marke-still', 'Review')); + if (gruppe === 'offen') marken.append(el('span', 'marke-fehlt', 'Protokoll fehlt')); + if (c.aufgaben.length) { + const erledigt = c.aufgaben.filter((a) => a.status === 'erledigt').length; + marken.append(el('span', 'marke-still', + `${erledigt}/${c.aufgaben.length} To-dos erledigt`)); + } + kopf.append(marken); + k.append(kopf); + + if (c.beschreibung) k.append(el('p', 'call__text', c.beschreibung)); + + /* Meeting-Link nur bei anstehenden Gesprächen -- danach ist er wertlos. */ + const knöpfe = el('div', 'kk__knoepfe'); + if (gruppe === 'anstehend' && istLink(c.ort)) { + const a = el('a', 'knopf knopf--klein', 'Call öffnen'); + a.href = c.ort; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + knöpfe.append(a); + } else if (c.ort && !istLink(c.ort)) { + k.append(el('p', 'call__ort', c.ort)); + } + + if (gruppe !== 'anstehend') { + const b = el('button', gruppe === 'offen' ? 'knopf knopf--klein' : 'knopf-still', + offen.has(c.id) ? 'Schließen' + : c.protokoll_id ? 'Protokoll ansehen' : 'Protokoll schreiben'); + b.type = 'button'; + b.addEventListener('click', () => { + if (offen.has(c.id)) offen.delete(c.id); else offen.add(c.id); + zeichne(); + }); + knöpfe.append(b); + } + if (knöpfe.childNodes.length) k.append(knöpfe); + + if (!offen.has(c.id)) return k; + + const auf = el('div', 'call__detail'); + if (c.protokoll_id) { + /* Fertiges Protokoll: lesen, nicht bearbeiten. Ein festgehaltenes + Gespräch nachträglich umzuschreiben wäre genau das, was ein + Protokoll wertlos macht. */ + if (c.punkte) { + auf.append(el('p', 'kk__schild', 'Besprochene Punkte')); + auf.append(el('p', 'call__abschnitt', c.punkte)); + } + if (c.entscheidungen) { + auf.append(el('p', 'kk__schild', 'Entscheidungen')); + auf.append(el('p', 'call__abschnitt', c.entscheidungen)); + } + if (c.aufgaben.length) { + auf.append(el('p', 'kk__schild', 'Daraus entstandene Aufgaben')); + const ul = el('ul', 'todo-liste'); + for (const a of c.aufgaben) { + const li = el('li'); + li.dataset.status = a.status; + li.append(el('span', 'todo-liste__titel', a.titel)); + const zus = [a.verantwortlich_name, a.frist ? 'bis ' + datum(a.frist) : null, + a.status === 'erledigt' ? 'erledigt' : null].filter(Boolean).join(' · '); + if (zus) li.append(el('span', 'kk__still', zus)); + ul.append(li); + } + auf.append(ul); + } + auf.append(el('p', 'call__quelle', + `Festgehalten von ${c.protokoll_von || '—'} am ${datum(c.protokoll_erstellt)}`)); + if (c.naechster_termin_id) { + const p = el('p', 'call__quelle', 'Folgetermin liegt im Kalender. '); + const a = el('a', 'call__verweis', 'Kalender öffnen'); + a.href = 'kalender.html'; + p.append(a); + auf.append(p); + } + } else { + auf.append(bogen(c)); + } + k.append(auf); + return k; + } + + /* ---------- Zeichnen ----------------------------------------------------- */ + + const GRUPPEN = [ + { schluessel: 'offen', titel: 'Protokoll fehlt', + frage: 'Diese Gespräche sind vorbei, aber noch nicht festgehalten.' }, + { schluessel: 'anstehend', titel: 'Steht an', frage: null }, + { schluessel: 'erledigt', titel: 'Festgehalten', frage: null }, + ]; + + function zeichne() { + const ziel = $('listen'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + + if (!daten.offen.length && !daten.anstehend.length && !daten.erledigt.length) { + const leer = el('div', 'leer-hinweis'); + leer.append(document.createTextNode('Noch keine Calls. Termine mit der Art „Call" oder „Review" ')); + const a = el('a', 'call__verweis', 'im Kalender'); + a.href = 'kalender.html'; + leer.append(a, document.createTextNode(' erscheinen automatisch hier.')); + ziel.append(leer); + return; + } + + for (const g of GRUPPEN) { + const liste = daten[g.schluessel]; + if (!liste.length) continue; + const s = el('section', 'gruppe'); + s.dataset.gruppe = g.schluessel; + const kopf = el('div', 'stufe__kopf'); + kopf.append(el('h2', 'stufe__titel', g.titel), el('span', 'stufe__zahl', String(liste.length))); + s.append(kopf); + if (g.frage) s.append(el('p', 'block__frage', g.frage)); + const box = el('div', 'gruppe__karten'); + for (const c of liste) box.append(karte(c, g.schluessel)); + s.append(box); + ziel.append(s); + } + } + + async function laden() { + try { + const a = await hole('/workspace/api/calls'); + if (!a.ok) { melde('Gespräche konnten nicht geladen werden.'); return; } + daten = await a.json(); + zeichne(); + } catch { /* umgeleitet */ } + } + + /* ---------- Start --------------------------------------------------------- */ + + (async () => { + try { + const a = await hole('/workspace/api/ich'); + if (!a.ok) { location.assign('/workspace/'); return; } + ich = await a.json(); + } catch { return; } + $('wer').textContent = ich.name + ' · ' + ich.rolle; + await laden(); + })(); +})(); diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index 2289e1c4..1ad9291d 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -29,7 +29,7 @@ { 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'], ziel: 'report.html' }, - { name: 'Calls', phase: 3, rollen: ['admin', 'creator', 'scout'] }, + { name: 'Calls & Protokolle', phase: 3, rollen: ['admin', 'creator', 'scout'], ziel: 'calls.html' }, { name: 'Scout-Pipeline', phase: 3, rollen: ['admin', 'scout'], ziel: 'scouting.html' }, { name: 'Automationen', phase: 4, rollen: ['admin'] }, ]; diff --git a/workspace/calls.html b/workspace/calls.html new file mode 100644 index 00000000..689139b0 --- /dev/null +++ b/workspace/calls.html @@ -0,0 +1,55 @@ + + +
+ + +Creator Workspace · Calls
+Gespräche
++ Jeder Call endet mit klaren To-dos – die landen direkt im Aufgabenbrett, + nicht in einem Dokument, das niemand mehr öffnet. +
+Gespräche werden geladen …