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