Workspace: Kalender mit Terminen, Calls und Aufgaben-Fristen
/workspace/kalender.html -- letzter grosser Baustein aus Phase 1.
Zeigt zwei Quellen in EINER Ansicht, wie im Konzept gefordert
("Kalender & Calls" neben "Deadlines" und "Wiedervorlagen"):
* eigene Termine (Arten: Call, Termin, Review)
* Fristen offener Aufgaben, nur lesend eingeblendet
Nach Tagen gruppiert statt als Monatsraster: Es geht um wenige, dafuer
konkrete Termine. Eine Liste liest sich dabei besser und funktioniert auf
dem Handy ohne Umbau. Zeitraum umschaltbar: 14 / 30 / 90 Tage, wobei 90
den 90-Tage-Plan aus dem Konzept abdeckt.
Sichtbarkeit wie bei den Aufgaben, wieder an einer Stelle definiert.
Geprueft mit drei Konten:
Chef 2 Termine + 2 Fristen | Luna 2 + 1 | Sam 0 + 1
Die Fristen folgen dabei exakt der Aufgaben-Regel -- Sam sieht nur seine.
Loeschen darf das Management und wer den Termin selbst angelegt hat.
Sonst koennte ein Creator einen Call absagen, den das Management
angesetzt hat. Geprueft: Luna auf Chefs Call 403, auf ihren eigenen 200,
Sam sieht ihn gar nicht (404).
Weiteres:
- Ort/Link wird nur dann als Verweis dargestellt, wenn er wirklich mit
http(s) beginnt -- sonst liesse sich javascript: einschleusen
- Zeitpunkt und Dauer werden geprueft (kaputtes Datum 400,
5000 Minuten 400)
- Beginn steht als lokale Zeit ohne Zeitzone: Alle Beteiligten sitzen in
derselben, eine falsch umgerechnete Uhrzeit waere schlimmer als gar
keine Umrechnung
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 <input type="datetime-local"> 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" });
|
||||
}
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
})();
|
||||
})();
|
||||
@@ -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'] },
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Kalender · Creator Workspace</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="theme-color" content="#05070d" />
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css" />
|
||||
<link rel="stylesheet" href="assets/css/start.css" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
|
||||
<header class="kopfleiste">
|
||||
<p class="marke"><a class="zurueck" href="start.html">Creator Workspace</a> · Kalender</p>
|
||||
<div class="kopfleiste__rechts">
|
||||
<span class="wer" id="wer">…</span>
|
||||
<button type="button" class="abmelden" id="abmelden">Abmelden</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="inhalt">
|
||||
|
||||
<section class="kopf-zeile">
|
||||
<div>
|
||||
<p class="marke">Termine</p>
|
||||
<h1 class="titel">Kalender & Calls</h1>
|
||||
<p class="unterzeile">Termine, Calls und Fristen aus den Aufgaben – in einer Ansicht.</p>
|
||||
</div>
|
||||
<button type="button" class="knopf knopf--klein" id="neu-oeffnen">Neuer Termin</button>
|
||||
</section>
|
||||
|
||||
<form class="neu" id="neu" hidden>
|
||||
<div class="neu__zeile">
|
||||
<label class="feld-schild" for="f-titel">Worum geht es?</label>
|
||||
<input id="f-titel" maxlength="160" placeholder="z. B. Wochen-Call mit Luna" required />
|
||||
</div>
|
||||
<div class="neu__raster">
|
||||
<div>
|
||||
<label class="feld-schild" for="f-art">Art</label>
|
||||
<select id="f-art">
|
||||
<option value="call" selected>Call</option>
|
||||
<option value="termin">Termin</option>
|
||||
<option value="review">Review</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="feld-schild" for="f-beginn">Beginn</label>
|
||||
<input id="f-beginn" type="datetime-local" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="feld-schild" for="f-dauer">Dauer (Minuten)</label>
|
||||
<input id="f-dauer" type="number" min="5" max="1440" step="5" value="30" />
|
||||
</div>
|
||||
<div id="feld-teilnehmer" hidden>
|
||||
<label class="feld-schild" for="f-teilnehmer">Mit wem</label>
|
||||
<select id="f-teilnehmer"><option value="">—</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="neu__zeile">
|
||||
<label class="feld-schild" for="f-ort">Ort oder Link <span class="leise">(optional)</span></label>
|
||||
<input id="f-ort" maxlength="400" placeholder="Discord, Meet-Link, Adresse …" />
|
||||
</div>
|
||||
<div class="neu__zeile">
|
||||
<label class="feld-schild" for="f-text">Notiz <span class="leise">(optional)</span></label>
|
||||
<textarea id="f-text" rows="2" maxlength="4000"></textarea>
|
||||
</div>
|
||||
<p class="fehler" id="neu-fehler" role="alert" aria-live="polite"></p>
|
||||
<div class="neu__knoepfe">
|
||||
<button type="submit" class="knopf knopf--klein" id="neu-speichern">Eintragen</button>
|
||||
<button type="button" class="abmelden" id="neu-abbrechen">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
|
||||
|
||||
<div class="zeitraum">
|
||||
<button type="button" class="schritt" data-tage="14">2 Wochen</button>
|
||||
<button type="button" class="schritt" data-tage="30">30 Tage</button>
|
||||
<button type="button" class="schritt" data-tage="90">90 Tage</button>
|
||||
</div>
|
||||
|
||||
<div id="liste" aria-busy="true"><p class="leise">Kalender wird geladen …</p></div>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/kalender.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user