Termine
+Kalender & Calls
+Termine, Calls und Fristen aus den Aufgaben – in einer Ansicht.
+Kalender wird geladen …
diff --git a/server/index.js b/server/index.js index 898ba903..16decd31 100644 --- a/server/index.js +++ b/server/index.js @@ -17,6 +17,7 @@ import { workspaceRouter } from "./workspace.js"; import { aufgabenRouter } from "./workspace-aufgaben.js"; import { personenRouter } from "./workspace-personen.js"; import { profilRouter } from "./workspace-profil.js"; +import { kalenderRouter } from "./workspace-kalender.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const SITE_DIR = join(__dirname, ".."); @@ -74,6 +75,7 @@ app.use(workspaceRouter); app.use(aufgabenRouter); app.use(personenRouter); app.use(profilRouter); +app.use(kalenderRouter); /* 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-kalender.js b/server/workspace-kalender.js new file mode 100644 index 00000000..6286c55c --- /dev/null +++ b/server/workspace-kalender.js @@ -0,0 +1,253 @@ +/* ===================================================================== + workspace-kalender.js — Termine, Calls und Fristen an einem Ort. + + Der Kalender zeigt zwei Quellen zusammen: + * eigene Termine aus dieser Tabelle + * Fristen aus den Aufgaben (nur lesend eingeblendet) + Das Konzept verlangt genau das -- "Kalender & Calls" neben "Deadlines" + und "Wiedervorlagen" in einer Ansicht, nicht in zwei getrennten Listen. + + Die Sichtbarkeit folgt derselben Regel wie bei den Aufgaben und ist + wieder an einer Stelle definiert. + ===================================================================== */ + +import express from "express"; +import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js"; + +export const kalenderRouter = express.Router(); + +const ARTEN = ["termin", "call", "review"]; +const TITEL_MAX = 160; +const TEXT_MAX = 4000; +const ORT_MAX = 400; + +const jetzt = () => new Date().toISOString(); + +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(); +} + +kalenderRouter.use("/workspace/api/termine", angemeldet); + +/* Wer sieht welchen Termin? Gleiche Logik wie bei den Aufgaben: + Management alles, sonst nur, woran man selbst beteiligt ist. */ +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, t.erstellt, t.erstellt_von, + pc.name AS creator_name, pt.name AS teilnehmer_name`; + +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`; + +/* ---------- Lesen ------------------------------------------------------- */ + +kalenderRouter.get("/workspace/api/termine", (req, res) => { + try { + const { wo, werte } = sichtbar(req.person); + + /* Zeitraum. Ohne Angabe: ab heute 00:00, 90 Tage nach vorn -- das + deckt den 90-Tage-Plan aus dem Konzept ab. */ + const von = /^\d{4}-\d{2}-\d{2}$/.test(String(req.query.von || "")) + ? String(req.query.von) : jetzt().slice(0, 10); + const bisTage = Math.min(Math.max(Number(req.query.tage) || 90, 1), 400); + const bis = new Date(Date.parse(von + "T00:00:00Z") + bisTage * 86400_000) + .toISOString().slice(0, 10); + + const termine = db().prepare(` + SELECT ${SPALTEN} ${VERBUND} + WHERE ${wo} AND t.beginn >= ? AND t.beginn < ? + ORDER BY t.beginn`).all(...werte, von, bis + "T23:59:59Z"); + + /* Fristen aus den Aufgaben -- nur zum Anzeigen, nicht bearbeitbar. + Die Sichtbarkeitsregel der Aufgaben gilt dabei unveraendert. */ + const aufgabenWo = req.person.rolle === "admin" + ? { wo: "1=1", werte: [] } + : req.person.rolle === "creator" + ? { wo: "(a.creator_id = ? OR a.verantwortlich_id = ?)", werte: [req.person.id, req.person.id] } + : { wo: "a.verantwortlich_id = ?", werte: [req.person.id] }; + + const fristen = db().prepare(` + SELECT a.id, a.titel, a.frist, a.status, a.prioritaet, pv.name AS verantwortlich_name + FROM aufgaben a LEFT JOIN personen pv ON pv.id = a.verantwortlich_id + WHERE ${aufgabenWo.wo} AND a.frist IS NOT NULL AND a.status <> 'erledigt' + AND a.frist >= ? AND a.frist <= ? + ORDER BY a.frist`).all(...aufgabenWo.werte, von, bis); + + res.json({ termine, fristen, von, bis }); + } catch (fehler) { + console.error("[workspace] Termine lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Prüfen ------------------------------------------------------- */ + +function pruefe(körper, { neu }) { + const fehler = []; + const aus = {}; + + if (neu || körper.titel !== undefined) { + const t = String(körper.titel ?? "").trim(); + if (t.length < 2) fehler.push("Titel fehlt."); + else if (t.length > TITEL_MAX) fehler.push("Titel ist zu lang."); + else aus.titel = t; + } + if (neu || körper.beginn !== undefined) { + const b = String(körper.beginn ?? "").trim(); + /* Erwartet wird, was ein liefert. */ + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(b) || Number.isNaN(Date.parse(b))) { + fehler.push("Zeitpunkt fehlt oder ist ungültig."); + } else aus.beginn = b; + } + if (körper.art !== undefined) { + if (!ARTEN.includes(körper.art)) fehler.push("Unbekannte Art."); + else aus.art = körper.art; + } + if (körper.dauer_min !== undefined) { + const d = Number(körper.dauer_min); + if (!Number.isInteger(d) || d < 5 || d > 24 * 60) fehler.push("Dauer muss zwischen 5 und 1440 Minuten liegen."); + else aus.dauer_min = d; + } + if (körper.ort !== undefined) { + const o = String(körper.ort ?? "").trim(); + if (o.length > ORT_MAX) fehler.push("Ort/Link ist zu lang."); + else aus.ort = o || null; + } + 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.erledigt !== undefined) aus.erledigt = körper.erledigt ? 1 : 0; + + for (const feld of ["creator_id", "teilnehmer_id"]) { + if (körper[feld] === undefined) continue; + const w = körper[feld]; + if (w === null || w === "") { aus[feld] = null; continue; } + const z = Number(w); + if (!Number.isInteger(z) || z < 1) fehler.push("Ungültige Zuordnung."); + else aus[feld] = z; + } + return { aus, fehler }; +} + +/* ---------- Anlegen ------------------------------------------------------ */ + +kalenderRouter.post("/workspace/api/termine", gleicheHerkunft, (req, res) => { + try { + const { aus, fehler } = pruefe(req.body || {}, { neu: true }); + if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); + + /* Wer nicht Management ist, legt nur für sich selbst an. */ + if (req.person.rolle !== "admin") { + aus.creator_id = req.person.rolle === "creator" ? req.person.id : null; + aus.teilnehmer_id = req.person.id; + } + for (const feld of ["creator_id", "teilnehmer_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 termine + (titel, beschreibung, art, beginn, dauer_min, ort, creator_id, teilnehmer_id, + erledigt, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,?,?,0,?,?)`).run( + aus.titel, aus.beschreibung ?? null, aus.art ?? "termin", aus.beginn, + aus.dauer_min ?? 30, aus.ort ?? null, aus.creator_id ?? null, + aus.teilnehmer_id ?? null, jetzt(), req.person.id); + + protokolliere("termin_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] Termin anlegen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ---------- Ändern und Löschen ------------------------------------------- */ + +kalenderRouter.patch("/workspace/api/termine/:id", gleicheHerkunft, (req, res) => { + 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 = db().prepare(`SELECT t.* ${VERBUND} WHERE ${wo} AND t.id = ?`).get(...werte, id); + if (!termin) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const { aus, fehler } = pruefe(req.body || {}, { neu: false }); + if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); + if (req.person.rolle !== "admin") { delete aus.creator_id; delete aus.teilnehmer_id; } + + const felder = Object.keys(aus); + if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); + + db().prepare(`UPDATE termine SET ${felder.map((f) => `${f} = ?`).join(", ")} WHERE id = ?`) + .run(...felder.map((f) => aus[f]), id); + + protokolliere("termin_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] Termin ändern:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +kalenderRouter.delete("/workspace/api/termine/:id", gleicheHerkunft, (req, res) => { + 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 = db().prepare(`SELECT t.id, t.titel, t.erstellt_von ${VERBUND} WHERE ${wo} AND t.id = ?`) + .get(...werte, id); + if (!termin) return res.status(404).json({ fehler: "nicht_gefunden" }); + + /* Löschen darf das Management -- und wer den Termin selbst angelegt + hat. Sonst könnte ein Creator einen Call absagen, den das + Management angesetzt hat. */ + if (req.person.rolle !== "admin" && termin.erstellt_von !== req.person.id) { + return res.status(403).json({ fehler: "nicht_erlaubt" }); + } + + db().prepare("DELETE FROM termine WHERE id = ?").run(id); + protokolliere("termin_geloescht", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} ${termin.titel}`.slice(0, 120), + }); + res.json({ ok: true }); + } catch (fehler) { + console.error("[workspace] Termin löschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); diff --git a/server/workspace.js b/server/workspace.js index 7f6ea80a..f78aeb4d 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -149,6 +149,28 @@ export function db() { geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL ); + /* Termine, Calls und Reviews. Der Beginn steht als lokale Zeit + (JJJJ-MM-TTThh:mm) -- genau so, wie ein datetime-local-Feld sie + liefert. Bewusst ohne Zeitzone: Alle Beteiligten sitzen in + derselben, und eine falsch umgerechnete Uhrzeit waere schlimmer + als gar keine Umrechnung. */ + CREATE TABLE IF NOT EXISTS termine ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + titel TEXT NOT NULL, + beschreibung TEXT, + art TEXT NOT NULL DEFAULT 'termin' + CHECK (art IN ('termin','call','review')), + beginn TEXT NOT NULL, + dauer_min INTEGER NOT NULL DEFAULT 30, + ort TEXT, + creator_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + teilnehmer_id INTEGER REFERENCES personen(id) ON DELETE SET NULL, + erledigt INTEGER NOT NULL DEFAULT 0, + erstellt TEXT NOT NULL, + erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_termine_beginn ON termine (beginn); + CREATE TABLE IF NOT EXISTS versuche ( ip TEXT NOT NULL, zeitpunkt TEXT NOT NULL @@ -282,6 +304,7 @@ const GESCHUETZT = { "/workspace/aufgaben.html": null, "/workspace/personen.html": ["admin"], "/workspace/profil.html": ["admin", "creator"], + "/workspace/kalender.html": null, }; workspaceRouter.use((req, res, next) => { diff --git a/workspace/assets/css/kalender.css b/workspace/assets/css/kalender.css new file mode 100644 index 00000000..da1c50ec --- /dev/null +++ b/workspace/assets/css/kalender.css @@ -0,0 +1,76 @@ +/* =================================================================== + Kalender. Nach Tagen gruppiert statt als Monatsraster: Es geht um + wenige, dafür konkrete Termine -- eine Liste liest sich dabei besser + und funktioniert auf dem Handy ohne Umbau. + =================================================================== */ + +.zeitraum { display: flex; gap: 8px; margin: 0 0 22px; flex-wrap: wrap; } +.zeitraum .schritt[aria-pressed="true"] { + background: rgba(63, 189, 245, .14); + border-color: rgba(63, 189, 245, .55); + color: var(--text); +} + +.tag { margin-bottom: 22px; } + +.tag__kopf { + display: flex; align-items: baseline; gap: 10px; + margin: 0 2px 9px; padding-bottom: 7px; + border-bottom: 1px solid var(--rand); +} +.tag__datum { font-weight: 700; font-size: .95rem; } +.tag__wochentag { font-size: .8rem; color: var(--text-still); } +.tag__marke { + margin-left: auto; padding: 1px 9px; border-radius: 999px; + font-size: .7rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; + background: rgba(63, 189, 245, .16); color: #7fd2fb; +} +/* Vergangene Tage treten zurück, bleiben aber lesbar. */ +.tag[data-vergangen="ja"] .tag__datum, +.tag[data-vergangen="ja"] .tag__wochentag { color: var(--text-still); } + +.eintrag { + display: grid; gap: 3px 14px; + grid-template-columns: 78px 1fr auto; + align-items: start; + padding: 11px 14px; margin-bottom: 8px; + background: rgba(255, 255, 255, .03); + border: 1px solid var(--rand); + border-left: 3px solid var(--rand); + border-radius: var(--radius-klein); +} +.eintrag[data-art="call"] { border-left-color: var(--akzent); } +.eintrag[data-art="review"] { border-left-color: #e0a33c; } +.eintrag[data-art="termin"] { border-left-color: var(--violett); } +.eintrag[data-art="frist"] { border-left-color: #d8656e; background: rgba(216, 101, 110, .05); } +.eintrag[data-erledigt="ja"] { opacity: .5; } + +.eintrag__zeit { + font-variant-numeric: tabular-nums; font-weight: 600; font-size: .88rem; + color: var(--text-leise); +} +.eintrag__titel { font-weight: 600; font-size: .93rem; } +.eintrag__zusatz { + grid-column: 2; display: flex; flex-wrap: wrap; gap: 8px; + font-size: .78rem; color: var(--text-still); +} +.eintrag__zusatz a { color: var(--akzent); } +.eintrag__knoepfe { display: flex; gap: 6px; align-items: center; } + +.eintrag__art { + padding: 1px 8px; border-radius: 999px; + font-size: .68rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; + background: rgba(255, 255, 255, .06); color: var(--text-still); +} + +.leer-hinweis { + padding: 26px 20px; text-align: center; + color: var(--text-still); font-size: .9rem; + border: 1px dashed var(--rand); border-radius: var(--radius); +} + +@media (max-width: 620px) { + .eintrag { grid-template-columns: 62px 1fr; } + .eintrag__knoepfe { grid-column: 1 / -1; margin-top: 8px; } + .eintrag__zusatz { grid-column: 1 / -1; } +} diff --git a/workspace/assets/js/kalender.js b/workspace/assets/js/kalender.js new file mode 100644 index 00000000..6ec113c1 --- /dev/null +++ b/workspace/assets/js/kalender.js @@ -0,0 +1,266 @@ +/* =================================================================== + Kalender: eigene Termine und Fristen aus den Aufgaben in einer Liste. + Fristen sind nur eingeblendet -- geändert werden sie im Aufgabenbrett. + =================================================================== */ +(() => { + 'use strict'; + + const $ = (id) => document.getElementById(id); + const WOCHENTAGE = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', + 'Donnerstag', 'Freitag', 'Samstag']; + const ARTNAME = { call: 'Call', termin: 'Termin', review: 'Review', frist: 'Frist' }; + + let ich = null; + let tage = 30; + + 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 heute = new Date().toISOString().slice(0, 10); + + 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; + } + + function datumHuebsch(iso) { + const [j, m, t] = iso.slice(0, 10).split('-'); + return `${t}.${m}.${j}`; + } + + function wochentag(iso) { + return WOCHENTAGE[new Date(iso.slice(0, 10) + 'T12:00:00').getDay()]; + } + + /* ---------- Zeichnen --------------------------------------------------- */ + + function eintragTermin(t) { + const k = el('div', 'eintrag'); + k.dataset.art = t.art; + k.dataset.erledigt = t.erledigt ? 'ja' : 'nein'; + + k.append(el('span', 'eintrag__zeit', t.beginn.slice(11, 16) + ' Uhr')); + k.append(el('span', 'eintrag__titel', t.titel)); + + const knoepfe = el('div', 'eintrag__knoepfe'); + knoepfe.append(el('span', 'eintrag__art', ARTNAME[t.art] || t.art)); + knoepfe.append(tuKnopf(t.erledigt ? 'wieder offen' : 'erledigt', async () => { + const a = await hole('/workspace/api/termine/' + t.id, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ erledigt: !t.erledigt }), + }); + if (!a.ok) { melde('Ändern ging nicht.'); return; } + await laden(); + })); + if (ich.rolle === 'admin' || t.erstellt_von === ich.id) { + knoepfe.append(tuKnopf('löschen', async () => { + if (!confirm('Termin "' + t.titel + '" löschen?')) return; + const a = await hole('/workspace/api/termine/' + t.id, { method: 'DELETE' }); + if (!a.ok) { melde('Löschen ging nicht.'); return; } + await laden(); + })); + } + k.append(knoepfe); + + const zusatz = el('div', 'eintrag__zusatz'); + if (t.dauer_min) zusatz.append(el('span', null, t.dauer_min + ' Min')); + if (t.teilnehmer_name) zusatz.append(el('span', null, '· mit ' + t.teilnehmer_name)); + if (ich.rolle === 'admin' && t.creator_name && t.creator_name !== t.teilnehmer_name) { + zusatz.append(el('span', null, '· Bereich ' + t.creator_name)); + } + if (t.ort) { + /* Links sollen klickbar sein, aber nur echte http(s)-Adressen -- + sonst liesse sich hier javascript: einschleusen. */ + if (/^https?:\/\//i.test(t.ort)) { + const a = el('a', null, t.ort.replace(/^https?:\/\//i, '').slice(0, 60)); + a.href = t.ort; a.target = '_blank'; a.rel = 'noopener noreferrer'; + zusatz.append(a); + } else { + zusatz.append(el('span', null, '· ' + t.ort)); + } + } + if (t.beschreibung) zusatz.append(el('span', null, '· ' + t.beschreibung)); + if (zusatz.childElementCount) k.append(zusatz); + return k; + } + + function eintragFrist(f) { + const k = el('div', 'eintrag'); + k.dataset.art = 'frist'; + k.append(el('span', 'eintrag__zeit', '—')); + k.append(el('span', 'eintrag__titel', f.titel)); + const knoepfe = el('div', 'eintrag__knoepfe'); + knoepfe.append(el('span', 'eintrag__art', 'Frist')); + const link = el('a', 'schritt', 'zur Aufgabe'); + link.href = 'aufgaben.html'; + knoepfe.append(link); + k.append(knoepfe); + const zusatz = el('div', 'eintrag__zusatz'); + zusatz.append(el('span', null, 'Priorität ' + f.prioritaet)); + if (f.verantwortlich_name) zusatz.append(el('span', null, '· ' + f.verantwortlich_name)); + k.append(zusatz); + return k; + } + + function tuKnopf(text, tu) { + const b = el('button', 'schritt', text); + b.type = 'button'; + b.addEventListener('click', async () => { + b.disabled = true; melde(''); + try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; } + }); + return b; + } + + function zeichne(daten) { + const ziel = $('liste'); + ziel.textContent = ''; + ziel.setAttribute('aria-busy', 'false'); + + /* Termine und Fristen nach Tag zusammenlegen. */ + const nachTag = new Map(); + for (const t of daten.termine) { + const tag = t.beginn.slice(0, 10); + if (!nachTag.has(tag)) nachTag.set(tag, { termine: [], fristen: [] }); + nachTag.get(tag).termine.push(t); + } + for (const f of daten.fristen) { + if (!nachTag.has(f.frist)) nachTag.set(f.frist, { termine: [], fristen: [] }); + nachTag.get(f.frist).fristen.push(f); + } + + if (!nachTag.size) { + ziel.append(el('p', 'leer-hinweis', + 'Für diesen Zeitraum ist nichts eingetragen.')); + return; + } + + for (const tag of [...nachTag.keys()].sort()) { + const block = el('section', 'tag'); + if (tag < heute) block.dataset.vergangen = 'ja'; + + const kopf = el('div', 'tag__kopf'); + kopf.append(el('span', 'tag__datum', datumHuebsch(tag))); + kopf.append(el('span', 'tag__wochentag', wochentag(tag))); + if (tag === heute) kopf.append(el('span', 'tag__marke', 'Heute')); + block.append(kopf); + + const { termine, fristen } = nachTag.get(tag); + for (const t of termine) block.append(eintragTermin(t)); + for (const f of fristen) block.append(eintragFrist(f)); + ziel.append(block); + } + } + + async function laden() { + melde(''); + try { + const a = await hole('/workspace/api/termine?tage=' + tage); + if (!a.ok) { melde('Kalender konnte nicht geladen werden.'); return; } + zeichne(await a.json()); + } catch { /* umgeleitet */ } + } + + /* ---------- Zeitraum ---------------------------------------------------- */ + + for (const b of document.querySelectorAll('.zeitraum .schritt')) { + b.setAttribute('aria-pressed', String(Number(b.dataset.tage) === tage)); + b.addEventListener('click', async () => { + tage = Number(b.dataset.tage); + for (const x of document.querySelectorAll('.zeitraum .schritt')) { + x.setAttribute('aria-pressed', String(x === b)); + } + await laden(); + }); + } + + /* ---------- Neuer Termin ------------------------------------------------ */ + + const umschalten = (auf) => { + $('neu').hidden = !auf; $('neu-oeffnen').hidden = auf; + if (auf) $('f-titel').focus(); + }; + $('neu-oeffnen').addEventListener('click', () => { + /* Vorbelegung: morgen 18:00 -- ein realistischer Zeitpunkt für einen + Call, spart in der Regel zwei Klicks. */ + const m = new Date(Date.now() + 86400_000); + m.setHours(18, 0, 0, 0); + $('f-beginn').value = new Date(m.getTime() - m.getTimezoneOffset() * 60000) + .toISOString().slice(0, 16); + umschalten(true); + }); + $('neu-abbrechen').addEventListener('click', () => { + $('neu').reset(); $('neu-fehler').textContent = ''; umschalten(false); + }); + + $('neu').addEventListener('submit', async (e) => { + e.preventDefault(); + $('neu-fehler').textContent = ''; + const knopf = $('neu-speichern'); + const daten = { + titel: $('f-titel').value, + art: $('f-art').value, + beginn: $('f-beginn').value, + dauer_min: Number($('f-dauer').value) || 30, + ort: $('f-ort').value, + beschreibung: $('f-text').value, + }; + if (ich.rolle === 'admin') { + const wahl = $('f-teilnehmer').value; + daten.teilnehmer_id = wahl || null; + daten.creator_id = wahl || null; + } + knopf.disabled = true; + try { + const a = await hole('/workspace/api/termine', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(daten), + }); + if (!a.ok) { + $('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; + return; + } + $('neu').reset(); umschalten(false); + await laden(); + } catch { /* umgeleitet */ } finally { knopf.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 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; + + if (ich.rolle === 'admin') { + $('feld-teilnehmer').hidden = false; + try { + const { personen } = await (await hole('/workspace/api/personen')).json(); + for (const p of personen) { + const o = document.createElement('option'); + o.value = String(p.id); + o.textContent = p.name + ' (' + p.rolle + ')'; + $('f-teilnehmer').append(o); + } + } catch { /* Zuordnung bleibt leer */ } + } + await laden(); + })(); +})(); diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index 705943d8..cd52a469 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -21,7 +21,7 @@ { name: 'Creator-Profile', phase: 1, rollen: ['admin'], ziel: 'profil.html' }, { name: 'Mein Profil', phase: 1, rollen: ['creator'], ziel: 'profil.html' }, { name: 'Aufgaben', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'aufgaben.html' }, - { name: 'Kalender', phase: 1, rollen: ['admin', 'creator', 'scout'] }, + { name: 'Kalender', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'kalender.html' }, { name: 'Dateien', phase: 1, rollen: ['admin', 'creator'] }, { name: 'LIVE-Analyse', phase: 2, rollen: ['admin', 'creator'] }, { name: 'Content-Planung', phase: 2, rollen: ['admin', 'creator'] }, diff --git a/workspace/kalender.html b/workspace/kalender.html new file mode 100644 index 00000000..355903fc --- /dev/null +++ b/workspace/kalender.html @@ -0,0 +1,93 @@ + + +
+ + +Creator Workspace · Kalender
+Termine
+Termine, Calls und Fristen aus den Aufgaben – in einer Ansicht.
+Kalender wird geladen …