diff --git a/server/pruef-ampel.png b/server/pruef-ampel.png new file mode 100644 index 00000000..08818225 Binary files /dev/null and b/server/pruef-ampel.png differ diff --git a/server/pruef-aufgabenbrett.png b/server/pruef-aufgabenbrett.png new file mode 100644 index 00000000..642a0130 Binary files /dev/null and b/server/pruef-aufgabenbrett.png differ diff --git a/server/pruef-felder.png b/server/pruef-felder.png new file mode 100644 index 00000000..b0f49429 Binary files /dev/null and b/server/pruef-felder.png differ diff --git a/server/pruef-freie-namen.mjs b/server/pruef-freie-namen.mjs new file mode 100644 index 00000000..17db8257 --- /dev/null +++ b/server/pruef-freie-namen.mjs @@ -0,0 +1,294 @@ +/* ===================================================================== + Prüfung: aussuchen ODER selbst eintragen (02.09.2026). + + Wunsch: "ich will da auch sachen selber noch eintragen können, also + aussuchen und selbst eintragen." — an allen fünf Stellen mit + Personenauswahl. + + Der gefährliche Teil ist NICHT, ob sich ein Name eintippen lässt. + Gefährlich ist, was danach mit dem Eintrag passiert: Er gehört zu + keiner Person mehr, und die halbe Anwendung sucht Einträge über + genau diese Verknüpfung. Ein frei benannter Eintrag könnte deshalb + lautlos verschwinden — aus der Liste, aus der Suche, aus der + Übersicht. Niemand bekäme eine Fehlermeldung; es wäre einfach nichts + mehr da. + + Deshalb prüft diese Datei vor allem eins: DASS NICHTS VERSCHWINDET. + + Dazu kommt die Wiederentdeckung eines Fehlers, der schon vorher da + war: Legte eine Managerin eine Aufgabe ohne Creator an, sah sie sie + selbst nicht mehr. Auch das steht hier — samt Gegenprobe. + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-frei-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4192"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4192"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 240_000).unref?.(); + +let fehler = 0; +let geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); + +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idCigdem = anlegen("Cigdem", "manager", "CODE-CIGDEM-01"); +const idBen = anlegen("Ben", "scout", "CODE-BEN-00001"); +const idTili = anlegen("Tili", "creator", "CODE-TILI-0001"); +/* Cigdem betreut niemanden -- genau der Fall, in dem der alte Fehler + zuschlug. */ +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)") + .run(idTili, idBen, jetzt); + +const zahl = (sql, ...w) => d.prepare(sql).get(...w)?.n ?? 0; + +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); + +async function anmelden(rolle, code, breite = 1440) { + const kontext = await browser.newContext({ viewport: { width: breite, height: 1000 } }); + const seite = await kontext.newPage(); + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + return seite; +} + +const api = (seite, pfad, optionen) => seite.evaluate(async ([p, o]) => { + const a = await fetch(p, { + credentials: "same-origin", + ...(o || {}), + headers: o?.koerper ? { "Content-Type": "application/json" } : (o?.kopf || undefined), + body: o?.koerper ? JSON.stringify(o.koerper) : undefined, + }); + return { status: a.status, daten: await a.json().catch(() => ({})) }; +}, [pfad, optionen]); + +const seiteDogi = await anmelden("admin", "CODE-DOGI-0001"); +const seiteCigdem = await anmelden("manager", "CODE-CIGDEM-01"); + +/* =================================================================== + TEIL 1 — Der alte Fehler: eine Aufgabe, die sich selbst versteckt + =================================================================== */ + +console.log("\n— Eine Aufgabe ohne Creator bleibt sichtbar —"); + +const ohne = await api(seiteCigdem, "/workspace/api/aufgaben", { + method: "POST", + koerper: { titel: "Ohne Zuordnung angelegt", prioritaet: "hoch" }, +}); +ok(ohne.status === 201, "Cigdem legt eine Aufgabe ohne Creator an"); + +const sichtCigdem = await api(seiteCigdem, "/workspace/api/aufgaben"); +ok(sichtCigdem.daten.aufgaben?.some((a) => a.id === ohne.daten.id), + "und SIEHT sie danach auch – genau das ging vorher verloren"); + +/* GEGENPROBE: Es sieht sie nicht plötzlich jeder. Wäre die Regel zu + weit geraten, wäre die Zeile darüber ebenfalls grün — und niemand + hätte gemerkt, dass Cigdem jetzt fremde Aufgaben sieht. */ +const seiteBen = await anmelden("scout", "CODE-BEN-00001"); +const sichtBen = await api(seiteBen, "/workspace/api/aufgaben"); +ok(!sichtBen.daten.aufgaben?.some((a) => a.id === ohne.daten.id), + "GEGENPROBE: Ben sieht Cigdems Aufgabe NICHT – die Regel ist nicht zu weit"); + +/* =================================================================== + TEIL 2 — Freie Namen: nichts darf verschwinden + =================================================================== */ + +console.log("\n— Aufgabe mit frei eingetragenem Namen —"); + +const frei = await api(seiteDogi, "/workspace/api/aufgaben", { + method: "POST", + koerper: { + titel: "Thumbnails von der Agentur", prioritaet: "mittel", + creator_extern: "Marke XY", verantwortlich_extern: "Agentur Müller", + }, +}); +ok(frei.status === 201, "eine Aufgabe mit zwei freien Namen lässt sich anlegen"); +ok(zahl("SELECT COUNT(*) AS n FROM aufgaben WHERE id = ? AND creator_id IS NULL AND creator_extern = 'Marke XY'", + frei.daten.id) === 1, + "der Name steht in der eigenen Spalte, die Verknüpfung bleibt leer"); + +const liste = await api(seiteDogi, "/workspace/api/aufgaben"); +const eintrag = liste.daten.aufgaben.find((a) => a.id === frei.daten.id); +ok(!!eintrag, "die Aufgabe steht in der Liste – sie ist nicht verschwunden"); +ok(eintrag?.creator_name === "Marke XY (extern)", + `der Name wird angezeigt und als extern gekennzeichnet ("${eintrag?.creator_name}")`); +ok(eintrag?.verantwortlich_name === "Agentur Müller (extern)", + `auch der Verantwortliche ("${eintrag?.verantwortlich_name}")`); + +const suche = await api(seiteDogi, "/workspace/api/suche?q=Thumbnails"); +const gefunden = JSON.stringify(suche.daten); +ok(gefunden.includes("Thumbnails von der Agentur"), + "die Suche findet sie – der häufigste Ort, an dem so etwas lautlos fehlt"); + +const uebersicht = await api(seiteDogi, "/workspace/api/uebersicht"); +ok((uebersicht.daten.offen ?? 0) >= 1, "die Übersicht zählt sie mit"); + +console.log("\n— Termin mit frei eingetragenem Namen —"); + +const morgen = new Date(Date.now() + 86400_000).toISOString().slice(0, 10); +const termin = await api(seiteDogi, "/workspace/api/termine", { + method: "POST", + koerper: { + titel: "Call mit der Agentur", art: "call", beginn: morgen + "T14:00", + dauer_min: 30, teilnehmer_extern: "Agentur Müller", + }, +}); +ok(termin.status === 201, "ein Termin mit freiem Namen lässt sich anlegen"); + +const termine = await api(seiteDogi, `/workspace/api/termine?von=${morgen}&tage=2`); +const t = termine.daten.termine?.find((x) => x.id === termin.daten.id); +ok(!!t, "er steht im Kalender"); +ok(t?.teilnehmer_name === "Agentur Müller (extern)", + `mit Namen und Kennzeichnung ("${t?.teilnehmer_name}")`); + +console.log("\n— Wiederholung mit frei eingetragenem Namen —"); + +const serie = await api(seiteDogi, "/workspace/api/serien", { + method: "POST", + koerper: { + titel: "Wochen-Call Agentur", art: "call", takt: "woechentlich", + start_tag: morgen, uhrzeit: "11:00", teilnehmer_extern: "Agentur Müller", + }, +}); +ok(serie.status === 201, "eine Wiederholung mit freiem Namen lässt sich anlegen"); +ok(zahl("SELECT COUNT(*) AS n FROM termine WHERE serie_id = ? AND teilnehmer_extern = 'Agentur Müller'", + serie.daten.id) > 20, + "der Name landet auf JEDER erzeugten Ausprägung, nicht nur auf der Regel"); + +console.log("\n— Bereichseintrag mit frei eingetragenem Namen —"); + +const eintragB = await api(seiteCigdem, "/workspace/api/bereich/live", { + method: "POST", + koerper: { art: "vorbereitung", titel: "Technikpartner meldet Ausfall", datum: morgen, + creator_extern: "Technikpartner" }, +}); +ok(eintragB.status === 201, "ein Bereichseintrag mit freiem Namen lässt sich anlegen" + + (eintragB.status === 201 ? "" : " · " + (eintragB.daten.fehler || eintragB.status))); + +const bereich = await api(seiteCigdem, "/workspace/api/bereich/live"); +const be = bereich.daten.eintraege?.find((x) => x.id === eintragB.daten.id); +ok(!!be, "Cigdem sieht ihn danach – auch ohne zugeteilten Creator"); +ok(be?.creator_name === "Technikpartner (extern)", + `mit Namen und Kennzeichnung ("${be?.creator_name}")`); + +console.log("\n— Grenzen —"); + +for (const [koerper, was, erwartet] of [ + [{ titel: "Langer Name", creator_extern: "A".repeat(200) }, "zu langer Name", 400], + [{ titel: "Beides gesetzt", creator_id: idTili, creator_extern: "Gast" }, "Person UND Name → Name gewinnt", 201], +]) { + const a = await api(seiteDogi, "/workspace/api/aufgaben", { method: "POST", koerper }); + ok(a.status === erwartet, `${was} → ${a.status}${a.status === erwartet ? "" : " · " + (a.daten.fehler || "")}`); + if (a.status === 201) { + ok(zahl("SELECT COUNT(*) AS n FROM aufgaben WHERE id = ? AND creator_id IS NULL", a.daten.id) === 1, + " und die Verknüpfung ist dabei wirklich leer – nie beides zugleich"); + } +} + +/* =================================================================== + TEIL 3 — Die Bedienung im Browser + =================================================================== */ + +console.log("\n— Suchen und eintragen im echten Browser —"); + +await seiteDogi.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" }); +await seiteDogi.waitForTimeout(800); +await seiteDogi.click("#neu-oeffnen").catch(() => {}); +await seiteDogi.waitForTimeout(400); + +/* Das Auswahlfeld ist durch wahl.js ein Knopf -- angeklickt wird der. */ +const knopf = seiteDogi.locator('#f-creator').locator('xpath=following-sibling::button').first(); +await knopf.click(); +await seiteDogi.waitForTimeout(300); + +const hatSuche = await seiteDogi.locator(".wahl2__suche").count(); +ok(hatSuche === 1, "die geöffnete Liste hat ein Suchfeld"); + +await seiteDogi.fill(".wahl2__suche", "Til"); +await seiteDogi.waitForTimeout(200); +const nachFilter = await seiteDogi.evaluate(() => + [...document.querySelectorAll(".wahl2__eintrag")].map((e) => e.textContent.trim())); +ok(nachFilter.some((t) => t.includes("Tili")), "Tippen filtert die Liste auf Tili"); +ok(!nachFilter.some((t) => t.includes("Cigdem")), "und blendet die anderen aus"); + +await seiteDogi.fill(".wahl2__suche", "Agentur Müller"); +await seiteDogi.waitForTimeout(200); +const eigen = await seiteDogi.locator(".wahl2__eintrag--eigen").count(); +ok(eigen === 1, "ein unbekannter Name wird als eigener Eintrag angeboten"); +await seiteDogi.locator(".wahl2__eintrag--eigen").click(); +await seiteDogi.waitForTimeout(200); + +const zustand = await seiteDogi.evaluate(() => ({ + extern: document.getElementById("f-creator").dataset.extern, + wert: document.getElementById("f-creator").value, + knopftext: document.querySelector('#f-creator + .wahl2__knopf .wahl2__text')?.textContent + || document.querySelector('.wahl2 .wahl2__text')?.textContent, + hinweis: document.querySelector(".wahl2__hinweis")?.textContent || "", +})); +ok(zustand.extern === "Agentur Müller", `der Name steht am Feld ("${zustand.extern}")`); +ok(zustand.wert === "", "und die Personenauswahl ist dabei leer – nie beides"); +ok(/Agentur Müller/.test(zustand.knopftext || ""), "der Knopf zeigt den Namen an"); +ok(zustand.hinweis.length > 20, + "unter dem Feld steht, was ein freier Name bedeutet"); + +/* GEGENPROBE: Ohne Erlaubnis darf NICHTS erfunden werden. Die + Säulenauswahl im Content ist genau so ein Feld -- eine Säule gehört + immer einem echten Creator, der Server lehnte alles andere ab. */ +await seiteDogi.goto(BASIS + "/workspace/content.html", { waitUntil: "networkidle" }); +await seiteDogi.waitForTimeout(800); +const freiFlags = await seiteDogi.evaluate(() => ({ + creator: document.getElementById("f-creator")?.dataset.frei, + saeule: document.getElementById("s-creator")?.dataset.frei, +})); +ok(freiFlags.creator === "ja", "im Content darf man beim Creator selbst eintragen"); +ok(freiFlags.saeule === "nein", + "GEGENPROBE: bei der Säulen-Zuordnung ausdrücklich NICHT – dort gäbe es der Server nicht her"); + +await seiteDogi.goto(BASIS + "/workspace/aufgaben.html", { waitUntil: "networkidle" }); +await seiteDogi.waitForTimeout(600); +await seiteDogi.click("#neu-oeffnen").catch(() => {}); +await seiteDogi.waitForTimeout(300); +await seiteDogi.screenshot({ path: "pruef-freie-namen.png", fullPage: false }); + +/* =================================================================== + Abschluss — die Anzahl zählt mit + =================================================================== */ +const ERWARTET = 31; +console.log(""); +ok(geprueft >= ERWARTET, + `es sind ${geprueft} Prüfungen gelaufen (erwartet mindestens ${ERWARTET})`); + +await browser.close(); +d.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(`\n${fehler ? "FEHLER: " + fehler : "Alles in Ordnung"} — ${geprueft} Prüfungen`); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-freie-namen.png b/server/pruef-freie-namen.png new file mode 100644 index 00000000..e43dadaa Binary files /dev/null and b/server/pruef-freie-namen.png differ diff --git a/server/pruef-handy.png b/server/pruef-handy.png new file mode 100644 index 00000000..557f7044 Binary files /dev/null and b/server/pruef-handy.png differ diff --git a/server/pruef-kalender-computer.png b/server/pruef-kalender-computer.png new file mode 100644 index 00000000..a69a3f18 Binary files /dev/null and b/server/pruef-kalender-computer.png differ diff --git a/server/pruef-kalender-handy.png b/server/pruef-kalender-handy.png new file mode 100644 index 00000000..b065742f Binary files /dev/null and b/server/pruef-kalender-handy.png differ diff --git a/server/pruef-lesbarkeit-scouting.png b/server/pruef-lesbarkeit-scouting.png new file mode 100644 index 00000000..ab4ab271 Binary files /dev/null and b/server/pruef-lesbarkeit-scouting.png differ diff --git a/server/pruef-lesbarkeit-wissen.png b/server/pruef-lesbarkeit-wissen.png new file mode 100644 index 00000000..21940952 Binary files /dev/null and b/server/pruef-lesbarkeit-wissen.png differ diff --git a/server/pruef-protokoll-auf.png b/server/pruef-protokoll-auf.png new file mode 100644 index 00000000..506a25c0 Binary files /dev/null and b/server/pruef-protokoll-auf.png differ diff --git a/server/pruef-protokoll-zu.png b/server/pruef-protokoll-zu.png new file mode 100644 index 00000000..e43469c4 Binary files /dev/null and b/server/pruef-protokoll-zu.png differ diff --git a/server/pruef-rollen-admin.png b/server/pruef-rollen-admin.png new file mode 100644 index 00000000..087c8756 Binary files /dev/null and b/server/pruef-rollen-admin.png differ diff --git a/server/pruef-rollen-creator.png b/server/pruef-rollen-creator.png new file mode 100644 index 00000000..9df28f5d Binary files /dev/null and b/server/pruef-rollen-creator.png differ diff --git a/server/pruef-rollen-manager.png b/server/pruef-rollen-manager.png new file mode 100644 index 00000000..9594d97c Binary files /dev/null and b/server/pruef-rollen-manager.png differ diff --git a/server/pruef-rollen-scout.png b/server/pruef-rollen-scout.png new file mode 100644 index 00000000..eeced4a5 Binary files /dev/null and b/server/pruef-rollen-scout.png differ diff --git a/server/pruef-rollen.mjs b/server/pruef-rollen.mjs new file mode 100644 index 00000000..96ffc274 --- /dev/null +++ b/server/pruef-rollen.mjs @@ -0,0 +1,241 @@ +/* ===================================================================== + Rollen-Rundgang: was eine Rolle SIEHT, muss auch funktionieren. + + Anlass (02.09.2026): "cigdem kriegt als manager gewisse sachen nicht + auf die sie sieht, check jede rolle ab und sieh zu dass alles + funktioniert in jeder rolle." + + --------------------------------------------------------------------- + WARUM DAS EINE EIGENE PRÜFUNG IST + + Die bestehenden Prüfungen sehen fast alle mit den Augen von DogFather + auf die Anwendung — und DogFather sieht alles. Genau deshalb fallen + Rollenfehler nicht auf: Eine Seite, die für einen Manager leer bleibt + oder mit 404 antwortet, ist in einer Admin-Prüfung tadellos. + + Hier läuft jede der vier Rollen über JEDE Seite, und geprüft wird + nicht "sieht gut aus", sondern dreierlei: + + 1. Keine Fehler in der Browser-Konsole. + 2. Kein Aufruf an den Server, der mit 4xx/5xx zurückkommt + (401 auf dem Anmeldeweg ausgenommen — der gehört dazu). + 3. Kein sichtbarer Knopf, der ins Leere führt: Jeder Verweis muss + ein Ziel haben, das es gibt. + + Dazu die eigentliche Frage: Wo eine Rolle etwas sehen DARF, darf die + Seite nicht wortlos leer sein. Ein leerer Bereich ohne Erklärung + sieht aus wie ein Fehler — und ist oft auch einer. + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-rollen-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4193"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_ACCESS_CODE_VANVAN = "VANVAN-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4193"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 540_000).unref?.(); + +let fehler = 0; +let geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---------- Ein Team mit echten Daten ---------------------------------- + Ohne Daten prüft ein Rundgang nur leere Seiten -- und leere Seiten + machen selten Fehler. Jede Rolle bekommt deshalb etwas zu sehen. */ + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const tag = (v) => new Date(Date.now() + v * 86400_000).toISOString().slice(0, 10); + +function person(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = person("Filipe", "admin", "CODE-DOGI-0001"); +const idCigdem = person("Cigdem", "manager", "CODE-CIGDEM-01"); +const idBen = person("Ben", "scout", "CODE-BEN-00001"); +const idTili = person("Tili", "creator", "CODE-TILI-0001"); +/* Zwei Neue ohne jede Zuteilung -- der Zustand am ersten Tag. */ +person("Neu-Scout", "scout", "CODE-NEU-000001"); +person("Neu-Manager", "manager", "CODE-NEUM-00001"); + +/* Cigdem betreut Tili, Ben ebenfalls -- so hat jede betreuende Rolle + etwas zu sehen. Genau diese Zuteilung fehlte im echten Bestand, und + deshalb blieben Cigdems Seiten leer. */ +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idCigdem, jetzt); +/* Ein zweiter Creator, damit auch der Scout WIRKLICH jemanden betreut. + Ohne ihn hiess ein Durchgang "Scout (mit Creator)" und prüfte in + Wahrheit denselben leeren Fall wie der ohne -- eine Prüfung, die + glaubt, zwei Fälle abzudecken, und in Wirklichkeit einen zweimal + nimmt. */ +const idLuna = person("Luna", "creator", "CODE-LUNA-0001"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idBen, jetzt); +d.prepare("INSERT INTO scout_zuteilung (scout_id, manager_id, seit) VALUES (?,?,?)").run(idBen, idCigdem, jetzt); + +d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, creator_id, verantwortlich_id, frist, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,?,?)`) + .run("Thumbnail bauen", "offen", "hoch", idTili, idTili, tag(2), jetzt, idDogi); +d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, teilnehmer_id, erledigt, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,0,?,?)`) + .run("Wochen-Call", "call", tag(1) + "T18:00", 30, idTili, idTili, jetzt, idDogi); +d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, creator_id, teilnehmer_id, erledigt, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,0,?,?)`) + .run("Vergangener Call", "call", tag(-1) + "T18:00", 30, idTili, idTili, jetzt, idDogi); +d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, status, creator_id, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,?,?)`) + .run("live", "vorbereitung", "Technik prüfen", tag(0), "offen", idTili, jetzt, idDogi); +d.prepare(`INSERT INTO eintraege (bereich, art, titel, datum, status, creator_id, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,?,?)`) + .run("content", "idee", "Videoidee Halloween", tag(0), "offen", idTili, jetzt, idDogi); +d.close(); + +/* ---------- Der Rundgang ------------------------------------------------ */ + +const SEITEN = [ + "start.html", "uebersicht.html", "aufgaben.html", "kalender.html", "calls.html", + "content.html", "bereich.html?b=live", "dateien.html", "personen.html", + "scouting.html", "steckbrief.html", "profil.html", "report.html", + "wissen.html", "startcheck.html", "automation.html", +]; + +/* Fünf Durchgänge, nicht vier. + + Der fünfte ist der wichtigste: eine Person MIT Rolle, aber OHNE + zugeteilte Creator. Das ist kein Sonderfall, sondern der Normalfall + am ersten Tag -- und es war Cigdems echte Lage, als sie meldete, + dass Dinge nicht gehen. Genau in diesem Zustand fielen beim ersten + Lauf drei Seiten durch, während dieselben Seiten mit Zuteilung + tadellos waren. Wer nur den bestückten Fall prüft, findet das nie. */ +const ROLLEN = [ + { rolle: "admin", code: "CODE-DOGI-0001", name: "DogFather" }, + { rolle: "manager", code: "CODE-CIGDEM-01", name: "Manager (mit Creator)" }, + { rolle: "scout", code: "CODE-BEN-00001", name: "Scout (mit Creator)" }, + { rolle: "creator", code: "CODE-TILI-0001", name: "Creator (Tili)" }, + { rolle: "scout", code: "CODE-NEU-000001", name: "Scout (OHNE Creator)" }, + { rolle: "manager", code: "CODE-NEUM-00001", name: "Manager (OHNE Creator)" }, +]; + +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); + +/* Fehler, die nichts mit uns zu tun haben. Bewusst kurz gehalten: Jede + Ausnahme hier ist eine Stelle, an der eine echte Panne durchrutschen + könnte. */ +const EGAL = [ + /favicon/i, + /Failed to load resource: net::ERR_/i, // abgebrochene Bilder beim Seitenwechsel +]; +const istEgal = (t) => EGAL.some((r) => r.test(t)); + +const berichte = []; + +for (const r of ROLLEN) { + const kontext = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const seite = await kontext.newPage(); + + const konsole = []; + const schlechteAntworten = []; + seite.on("console", (m) => { if (m.type() === "error" && !istEgal(m.text())) konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); + seite.on("response", (a) => { + const u = a.url(); + if (!u.includes("/workspace/")) return; + /* 401 auf dem Anmeldeweg gehoert dazu -- die Seite fragt, bekommt + "nicht angemeldet" und leitet um. Alles andere nicht. */ + if (a.status() === 401 && /\/api\/ich|\/api\/sitzung/.test(u)) return; + if (a.status() >= 400) schlechteAntworten.push(`${a.status()} ${u.replace(BASIS, "")}`); + }); + + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click(`.rolle[data-rolle="${r.rolle}"]`); + await seite.fill("#code", r.code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + + console.log(`\n===== ${r.name} =====`); + + for (const pfad of SEITEN) { + konsole.length = 0; + schlechteAntworten.length = 0; + await seite.goto(BASIS + "/workspace/" + pfad, { waitUntil: "networkidle" }); + await seite.waitForTimeout(700); + + const zustand = await seite.evaluate(() => { + const sichtbar = (e) => { + const k = e.getBoundingClientRect(); + return k.width > 0 && k.height > 0 && getComputedStyle(e).visibility !== "hidden"; + }; + /* Verweise, die ins Leere zeigen. Ein Knopf, der nichts tut, ist + schlimmer als kein Knopf: Man klickt ihn immer wieder. */ + const tote = [...document.querySelectorAll("a[href]")] + .filter(sichtbar) + .filter((a) => { + const h = a.getAttribute("href"); + return !h || h === "#" || h === "javascript:void(0)"; + }) + .map((a) => a.textContent.trim().slice(0, 40)); + /* Sichtbarer Text insgesamt -- eine Seite, auf der praktisch + nichts steht, ist entweder leer oder kaputt. */ + const text = (document.querySelector("main")?.innerText || "").trim(); + return { + tote, + laenge: text.length, + /* Ein sichtbarer Hinweis, der die Leere erklärt. */ + erklaert: [...document.querySelectorAll('.leer-hinweis, .leer, .k-leer, .block__frage')] + .some((e) => e.getBoundingClientRect().height > 0 && e.textContent.trim().length > 25), + /* Steht ein Ladehinweis noch da, ist etwas haengengeblieben. */ + haengt: [...document.querySelectorAll('[aria-busy="true"]')].length, + ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); + + const probleme = []; + if (konsole.length) probleme.push("Konsole: " + konsole.slice(0, 2).join(" | ").slice(0, 160)); + if (schlechteAntworten.length) probleme.push("Server: " + [...new Set(schlechteAntworten)].slice(0, 3).join(", ")); + if (zustand.tote.length) probleme.push("tote Verweise: " + zustand.tote.slice(0, 3).join(", ")); + if (zustand.haengt) probleme.push(`${zustand.haengt}× haengt beim Laden`); + if (zustand.laenge < 120) probleme.push(`fast kein Inhalt (${zustand.laenge} Zeichen)`); + if (zustand.ueber > 0) probleme.push(`ragt ${zustand.ueber}px heraus`); + + ok(!probleme.length, `${r.name.padEnd(18)} ${pfad.padEnd(22)} ${probleme.join(" · ")}`); + if (probleme.length) berichte.push({ rolle: r.name, pfad, probleme }); + } + + await seite.screenshot({ path: `pruef-rollen-${r.rolle}.png`, fullPage: false }); + await kontext.close(); +} + +await browser.close(); + +/* ---------- Abschluss --------------------------------------------------- */ + +console.log(""); +const ERWARTET = ROLLEN.length * SEITEN.length; +ok(geprueft >= ERWARTET, + `jede Rolle war auf jeder Seite (${geprueft} von ${ERWARTET} Durchgängen)`); + +if (berichte.length) { + console.log("\n--- Was zu tun ist ---"); + for (const b of berichte) console.log(` ${b.rolle} · ${b.pfad}\n ${b.probleme.join("\n ")}`); +} + +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(`\n${fehler ? "FEHLER: " + fehler : "Alles in Ordnung"} — ${geprueft} Prüfungen`); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-serien-computer.png b/server/pruef-serien-computer.png new file mode 100644 index 00000000..5841916c Binary files /dev/null and b/server/pruef-serien-computer.png differ diff --git a/server/pruef-serien-handy.png b/server/pruef-serien-handy.png new file mode 100644 index 00000000..2997c0c6 Binary files /dev/null and b/server/pruef-serien-handy.png differ diff --git a/server/pruef-serien-liste.png b/server/pruef-serien-liste.png new file mode 100644 index 00000000..6028c505 Binary files /dev/null and b/server/pruef-serien-liste.png differ diff --git a/server/pruef-sprung.png b/server/pruef-sprung.png new file mode 100644 index 00000000..542c4e0a Binary files /dev/null and b/server/pruef-sprung.png differ diff --git a/server/pruef-start-computer.png b/server/pruef-start-computer.png new file mode 100644 index 00000000..c5fcf0e7 Binary files /dev/null and b/server/pruef-start-computer.png differ diff --git a/server/pruef-start-creator.png b/server/pruef-start-creator.png new file mode 100644 index 00000000..2c7110b5 Binary files /dev/null and b/server/pruef-start-creator.png differ diff --git a/server/pruef-start-handy.png b/server/pruef-start-handy.png new file mode 100644 index 00000000..c0719430 Binary files /dev/null and b/server/pruef-start-handy.png differ diff --git a/server/pruef-uebersicht-computer.png b/server/pruef-uebersicht-computer.png new file mode 100644 index 00000000..2282f5e9 Binary files /dev/null and b/server/pruef-uebersicht-computer.png differ diff --git a/server/pruef-uebersicht-creator.png b/server/pruef-uebersicht-creator.png new file mode 100644 index 00000000..e2014a99 Binary files /dev/null and b/server/pruef-uebersicht-creator.png differ diff --git a/server/pruef-uebersicht-handy.png b/server/pruef-uebersicht-handy.png new file mode 100644 index 00000000..ceab9d1a Binary files /dev/null and b/server/pruef-uebersicht-handy.png differ diff --git a/server/workspace-aufgaben.js b/server/workspace-aufgaben.js index e9acdf39..01b5e845 100644 --- a/server/workspace-aufgaben.js +++ b/server/workspace-aufgaben.js @@ -11,6 +11,7 @@ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, betreutWo, istLeitung, heuteLokal, ROLLEN_SORTIERUNG, betreuteIds, + externPruefen, externSql, } from "./workspace.js"; export const aufgabenRouter = express.Router(); @@ -58,7 +59,10 @@ export function sichtbar(person) { case "admin": return { wo: "1=1", werte: [] }; case "creator": - return { wo: "(a.creator_id = ? OR a.verantwortlich_id = ?)", werte: [person.id, person.id] }; + return { + wo: "(a.creator_id = ? OR a.verantwortlich_id = ? OR a.erstellt_von = ?)", + werte: [person.id, person.id, person.id], + }; /* MANAGER STAND HIER BIS ZUM 01.09.2026 NICHT -- er fiel in den Default und sah damit auf dem Aufgabenbrett GAR NICHTS. Ein leeres Brett sieht aus wie "nichts zu tun", nicht wie ein Fehler; @@ -67,12 +71,26 @@ export function sichtbar(person) { Jetzt gilt fuer ihn dieselbe Regel wie fuer einen Scout: eigene Aufgaben plus die der Creator, die er betreut. Das ist genau das Gewuenschte -- "nur ihre zugeteilten Aufgaben von ihren Creator". */ + /* "ODER ich habe sie selbst angelegt" kam am 02.09.2026 dazu, und es + war ein echter Fehler, dass es fehlte. + + Legte eine Managerin eine Aufgabe an und liess das Feld "Creator" + auf "—" (oder trug seit heute einen freien Namen ein), war + creator_id leer und verantwortlich_id ebenfalls. Damit traf KEINE + der beiden Bedingungen zu: Die Aufgabe verschwand in dem Moment, + in dem sie gespeichert wurde -- nur DogFather sah sie noch. Kein + Fehler, keine Meldung, die Aufgabe war einfach weg. + + Es bleibt dabei, dass nur DogFather ALLES sieht. Hier kommt + niemand an etwas Fremdes heran -- nur an das, was er selbst + geschrieben hat. */ case "scout": case "manager": { + const eigen = "(a.verantwortlich_id = ? OR a.erstellt_von = ?)"; const b = betreutWo(person, "a.creator_id"); return b - ? { wo: `(a.verantwortlich_id = ? OR ${b.wo})`, werte: [person.id, ...b.werte] } - : { wo: "a.verantwortlich_id = ?", werte: [person.id] }; + ? { wo: `(${eigen} OR ${b.wo})`, werte: [person.id, person.id, ...b.werte] } + : { wo: eigen, werte: [person.id, person.id] }; } default: return { wo: "0=1", werte: [] }; // unbekannte Rolle sieht nichts @@ -87,7 +105,9 @@ function darfAendern(person, aufgabe) { 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`; + a.creator_extern, a.verantwortlich_extern, + ${externSql("pc.name", "a.creator_extern")} AS creator_name, + ${externSql("pv.name", "a.verantwortlich_extern")} AS verantwortlich_name`; const VERBUND = ` FROM aufgaben a @@ -194,6 +214,13 @@ function pruefeFelder(körper, { neu }) { if (!Number.isInteger(zahl) || zahl < 1) fehler.push("Ungültige Zuordnung."); else aus[feld] = zahl; } + + /* Frei eingetragene Namen statt einer Person -- eine Agentur, eine + Marke, jemand ohne Konto. Sie schlagen die Auswahl: Wer tippt, + meint das Getippte, und die Verknuepfung faellt weg. */ + externPruefen(körper, aus, "creator", fehler); + externPruefen(körper, aus, "verantwortlich", fehler); + return { aus, fehler }; } @@ -385,10 +412,11 @@ aufgabenRouter.post("/workspace/api/aufgaben", gleicheHerkunft, (req, res) => { const { lastInsertRowid } = db().prepare(` INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, - frist, erstellt, erstellt_von) - VALUES (?,?,?,?,?,?,?,?,?)`).run( + creator_extern, verantwortlich_extern, 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.creator_extern ?? null, aus.verantwortlich_extern ?? null, aus.frist ?? null, jetzt(), req.person.id); protokolliere("aufgabe_angelegt", { diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index d1d77707..3d5fda3d 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -17,6 +17,7 @@ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds, istLeitung, istDogFather, + externPruefen, externSql, } from "./workspace.js"; export const bereicheRouter = express.Router(); @@ -199,17 +200,32 @@ export function sichtbar(person) { istLeitung und bleibt unveraendert -- sonst haette dieser eine Wunsch stillschweigend seine halben Rechte mitgenommen. */ if (istDogFather(person)) return { wo: "1=1", werte: [] }; - if (person.rolle === "creator") return { wo: "e.creator_id = ?", werte: [person.id] }; - /* Ein Scout sieht die Bereiche der Creator, die er betreut -- und ohne - Zuteilung gar nichts (null heisst: Bereich existiert fuer dich nicht). */ - return betreutWo(person, "e.creator_id"); + + /* "ODER ich habe ihn selbst angelegt" kam am 02.09.2026 dazu -- aus + demselben Grund wie bei den Aufgaben: Ein Eintrag ohne creator_id + (Feld auf "—" gelassen oder seit heute ein freier Name) traf keine + der Bedingungen und war fuer den, der ihn gerade geschrieben hatte, + sofort unsichtbar. Nur DogFather sah ihn noch. + + Es sieht weiterhin niemand etwas Fremdes -- nur das Eigene. */ + if (person.rolle === "creator") { + return { wo: "(e.creator_id = ? OR e.erstellt_von = ?)", werte: [person.id, person.id] }; + } + /* Ein Scout sieht die Bereiche der Creator, die er betreut -- dazu, + was er selbst eingetragen hat. */ + const b = betreutWo(person, "e.creator_id"); + return b + ? { wo: `(e.erstellt_von = ? OR ${b.wo})`, werte: [person.id, ...b.werte] } + : { wo: "e.erstellt_von = ?", werte: [person.id] }; } const SPALTEN = ` e.id, e.bereich, e.art, e.titel, e.text, e.datum, e.bewertung, e.dringlichkeit, e.status, e.creator_id, e.erstellt, e.erstellt_von, e.geaendert, e.hook, e.format, e.saeule_id, e.geplant, - pc.name AS creator_name, pe.name AS erstellt_name, + e.creator_extern, + ${externSql("pc.name", "e.creator_extern")} AS creator_name, + pe.name AS erstellt_name, (SELECT name FROM content_saeulen s WHERE s.id = e.saeule_id) AS saeule_name, (SELECT slot FROM content_saeulen s WHERE s.id = e.saeule_id) AS saeule_slot`; @@ -345,6 +361,13 @@ function pruefe(bereich, körper, { neu }) { else aus.creator_id = z; } } + + /* Ein frei eingetragener Name statt eines Creators -- eine Marke, ein + Partner, jemand ohne Konto im Workspace. Er schlaegt die Auswahl: + Wer tippt, meint das Getippte. Der Eintrag gehoert dann zu keinem + Konto; sichtbar bleibt er ueber erstellt_von (siehe sichtbar()). */ + externPruefen(körper, aus, "creator", fehler); + return { aus, fehler }; } @@ -366,7 +389,14 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re Scout hat gar keinen eigenen Betreuungsbereich. */ if (req.person.rolle === "creator") { aus.creator_id = req.person.id; - } else if (req.person.rolle === "scout") { + aus.creator_extern = null; // der eigene Bereich gehört ihm, nicht einem Namen + } else if (req.person.rolle === "scout" && !aus.creator_extern) { + /* Der freie Name nimmt diesen Zweig ausdrücklich aus: Wer "Marke + XY" eintippt, will keinen seiner Creator zugeordnet bekommen. + Ohne diese Ausnahme hätte die Notlösung darunter den getippten + Namen stillschweigend durch den erstbesten Creator ersetzt -- + der Eintrag stünde dann bei jemandem, der nichts damit zu tun + hat. Sichtbar bleibt er über erstellt_von. */ const w = Number(aus.creator_id); if (!darfCreator(req.person, w)) { const erster = betreuteIds(req.person)[0]; @@ -390,11 +420,11 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re const { lastInsertRowid } = db().prepare(` INSERT INTO eintraege (bereich, art, titel, text, datum, bewertung, dringlichkeit, status, - creator_id, erstellt, erstellt_von, hook, format, saeule_id, geplant) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( + creator_id, creator_extern, erstellt, erstellt_von, hook, format, saeule_id, geplant) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( bereich, aus.art, aus.titel, aus.text ?? null, aus.datum, aus.bewertung ?? null, aus.dringlichkeit ?? "mittel", aus.status ?? "offen", - aus.creator_id ?? null, jetzt(), req.person.id, + aus.creator_id ?? null, aus.creator_extern ?? null, jetzt(), req.person.id, aus.hook ?? null, aus.format ?? null, aus.saeule_id ?? null, aus.geplant ?? null); protokolliere("eintrag_angelegt", { diff --git a/server/workspace-calls.js b/server/workspace-calls.js index 47122337..13756637 100644 --- a/server/workspace-calls.js +++ b/server/workspace-calls.js @@ -17,11 +17,18 @@ ===================================================================== */ import express from "express"; -import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js"; +import { + db, protokolliere, echteIp, sitzungLesen, einstellung, einstellungSetzen, istLeitung, +} from "./workspace.js"; import { sichtbar } from "./workspace-kalender.js"; export const callRouter = express.Router(); +/* Wie lange nach dem geplanten Ende ein Call noch als "läuft gerade" + gilt. Gespräche ziehen sich; wer eine Viertelstunde später beitritt, + soll den Knopf noch finden. */ +const NACHLAUF_MIN = 60; + const TITEL_MAX = 160; const TEXT_MAX = 6000; const TODOS_MAX = 12; @@ -113,6 +120,17 @@ callRouter.get("/workspace/api/calls", (req, res) => { const grenze = jetztLokal(); res.json({ jetzt: grenze, + /* Wo der Call stattfindet, wenn am Termin selbst kein Link steht. + Vorher gab es den Knopf "Call öffnen" nur, wenn jemand von Hand + einen Link eingetragen hatte -- stand dort "Hier" oder "Discord", + war da nichts zum Anklicken, und die Frage "wo führe ich den + Call?" blieb unbeantwortet. Jetzt hat das Team einen festen + Sprachkanal, und jeder Call zeigt darauf. + + Der Link liegt in den Einstellungen und nicht fest im Code: Ein + Kanal kann umziehen, und dann darf das kein Programmierauftrag + sein. */ + call_link: einstellung("call_link", "") || "", /* Die Reihenfolge der Gruppen ist die Dringlichkeit: Was fehlt, steht oben -- nicht was als Naechstes kommt. */ offen: alle.filter((c) => c.beginn <= grenze && !c.protokoll_id).reverse(), @@ -127,6 +145,43 @@ callRouter.get("/workspace/api/calls", (req, res) => { /* ---------- Protokoll schreiben ----------------------------------------- */ +/* ---------- Wo die Calls stattfinden ------------------------------------- + + Ein Link, den das Management einmal setzt (der Discord-Sprachkanal des + Teams). Er beantwortet die Frage "wo führe ich den Call?" ein für alle + Mal, statt sie bei jedem Termin neu zu stellen. + + Ein Termin darf trotzdem seinen eigenen Ort haben -- ein Gespräch mit + einer Agentur findet nun einmal woanders statt. Der eigene Ort + gewinnt; dieser hier ist die Antwort, wenn keiner eingetragen wurde. */ +callRouter.put("/workspace/api/call-link", gleicheHerkunft, (req, res) => { + try { + if (!istLeitung(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + const roh = String(req.body?.link ?? "").trim(); + if (roh) { + let u; + try { u = new URL(roh); } catch { u = null; } + /* Nur http(s). Ohne diese Prüfung liesse sich hier ein + javascript:-Verweis hinterlegen, den anschliessend JEDER im Team + mit einem Klick ausfuehrt -- der Link steht ja auf jeder + Call-Karte. */ + if (!u || (u.protocol !== "https:" && u.protocol !== "http:")) { + return res.status(400).json({ fehler: "Das ist keine gültige http(s)-Adresse." }); + } + if (roh.length > 400) return res.status(400).json({ fehler: "Der Link ist zu lang." }); + } + einstellungSetzen("call_link", roh, { ...req.person, ip: echteIp(req) }); + protokolliere("call_link_gesetzt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: (roh || "geleert").slice(0, 120), + }); + res.json({ ok: true, call_link: roh }); + } catch (fehler) { + console.error("[workspace] Call-Link setzen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + callRouter.post("/workspace/api/calls/:id/protokoll", gleicheHerkunft, (req, res) => { const d = db(); try { diff --git a/server/workspace-dateien.js b/server/workspace-dateien.js index 0de9f334..bda93bd6 100644 --- a/server/workspace-dateien.js +++ b/server/workspace-dateien.js @@ -30,6 +30,7 @@ import { join, extname, basename } from "node:path"; import { mkdirSync, createReadStream, writeFileSync, unlinkSync, statSync } from "node:fs"; import { db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, betreutWo, darfCreator, betreuteIds, istLeitung, istDogFather, ROLLEN_SORTIERUNG, + externSql, EXTERN_MAX, } from "./workspace.js"; export const dateienRouter = express.Router(); @@ -149,7 +150,9 @@ export function freigabenSetzen(dateiId, ids, person) { const SPALTEN = ` d.id, d.name_original, d.groesse, d.typ, d.status, d.notiz, d.creator_id, d.hochgeladen_von, d.erstellt, d.geaendert, - pc.name AS creator_name, ph.name AS hochgeladen_name`; + d.creator_extern, + ${externSql("pc.name", "d.creator_extern")} AS creator_name, + ph.name AS hochgeladen_name`; const VERBUND = ` FROM dateien d @@ -245,6 +248,20 @@ dateienRouter.post("/workspace/api/dateien", creatorId = z; } } + /* Ein frei eingetragener Name statt einer Person -- eine Agentur, + eine Marke, ein Partner ohne Konto. Er kommt wie der Dateiname + URL-kodiert im Kopf, sonst zerbrechen Umlaute: Kopfzeilen sind + streng genommen nur ASCII, und "Müller" käme als "Müller" an. + Der Name schlaegt die Auswahl -- wer tippt, meint das Getippte. + Sichtbar bleibt die Datei ueber hochgeladen_von. */ + let creatorExtern = null; + if (req.person.rolle !== "creator") { + let roh = ""; + try { roh = decodeURIComponent(req.get("x-creator-extern") || ""); } catch { roh = ""; } + roh = roh.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, EXTERN_MAX); + if (roh) { creatorExtern = roh; creatorId = null; } + } + /* Ein Scout laedt ohne Bereichszuordnung ab -- die Datei sehen dann er selbst und das Management. Einem Creator direkt etwas in den Bereich zu legen bleibt dem Management vorbehalten, so wie im diff --git a/server/workspace-kalender.js b/server/workspace-kalender.js index 81433127..db2d285b 100644 --- a/server/workspace-kalender.js +++ b/server/workspace-kalender.js @@ -14,6 +14,7 @@ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, istLeitung, termineSichtbar, heuteLokal, + externPruefen, externSql, } from "./workspace.js"; import { nachfuellenAlle, serienPruefen, serieAnlegen, zuordnungErzwingen, @@ -58,11 +59,17 @@ kalenderRouter.use("/workspace/api/termine", angemeldet); -suche.js holen ihn von hier. */ export const sichtbar = (person) => termineSichtbar(person, "t"); +/* Der Name faellt auf den frei eingetragenen Text zurueck, wenn keine + Person verknuepft ist. Beide Namen greifen auf dasselbe Feld zurueck, + weil das Formular auch nur EINE Auswahl hat ("Mit wem") und aus ihr + beide Spalten fuellt -- eine getrennte Behandlung waere hier eine + Unterscheidung ohne Unterschied. */ 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, - t.serie_id, t.serie_tag, - pc.name AS creator_name, pt.name AS teilnehmer_name`; + t.serie_id, t.serie_tag, t.teilnehmer_extern, + ${externSql("pc.name", "t.teilnehmer_extern")} AS creator_name, + ${externSql("pt.name", "t.teilnehmer_extern")} AS teilnehmer_name`; const VERBUND = ` FROM termine t @@ -168,6 +175,15 @@ function pruefe(körper, { neu }) { if (!Number.isInteger(z) || z < 1) fehler.push("Ungültige Zuordnung."); else aus[feld] = z; } + + /* Ein frei eingetragener Name statt einer Person ("Agentur Müller", + eine Marke, ein Gast). Er schlaegt die Auswahl: Wer tippt, meint + das Getippte. Weil das Formular mit EINER Auswahl beide Spalten + fuellt, faellt hier auch creator_id -- sonst gehoerte der Termin + weiterhin einem Creator, waehrend "mit wem" jemand anderes sagt. */ + externPruefen(körper, aus, "teilnehmer", fehler); + if (aus.teilnehmer_extern) aus.creator_id = null; + return { aus, fehler }; } @@ -178,10 +194,14 @@ kalenderRouter.post("/workspace/api/termine", gleicheHerkunft, (req, res) => { 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. */ + /* Wer nicht Management ist, legt nur für sich selbst an. Das Feld + "Mit wem" bekommt er gar nicht zu sehen -- ein trotzdem + mitgeschickter freier Name faellt hier weg, nicht erst in der + Anzeige. */ if (!istLeitung(req.person)) { aus.creator_id = req.person.rolle === "creator" ? req.person.id : null; aus.teilnehmer_id = req.person.id; + aus.teilnehmer_extern = null; } 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])) { @@ -217,11 +237,11 @@ kalenderRouter.post("/workspace/api/termine", gleicheHerkunft, (req, res) => { 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( + teilnehmer_extern, 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); + aus.teilnehmer_id ?? null, aus.teilnehmer_extern ?? null, jetzt(), req.person.id); protokolliere("termin_angelegt", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), @@ -247,7 +267,9 @@ kalenderRouter.patch("/workspace/api/termine/:id", gleicheHerkunft, (req, res) = const { aus, fehler } = pruefe(req.body || {}, { neu: false }); if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); - if (!istLeitung(req.person)) { delete aus.creator_id; delete aus.teilnehmer_id; } + if (!istLeitung(req.person)) { + delete aus.creator_id; delete aus.teilnehmer_id; delete aus.teilnehmer_extern; + } const felder = Object.keys(aus); if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); diff --git a/server/workspace-reports.js b/server/workspace-reports.js index 72419d96..68d8f735 100644 --- a/server/workspace-reports.js +++ b/server/workspace-reports.js @@ -31,15 +31,27 @@ const tagOffset = (tage) => new Date(Date.now() - tage * 86400_000).toISOString( function angemeldet(req, res, next) { const person = sitzungLesen(req); if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); - /* Ein Scout ohne zugeteilte Creator hat hier nichts zu suchen -- und - erfaehrt per 404 auch nicht, dass es den Bereich gibt. */ - if (person.rolle === "scout" && !betreuteIds(person).length) { - return res.status(404).json({ fehler: "nicht_gefunden" }); - } req.person = person; next(); } +/* HIER STAND BIS ZUM 02.09.2026 EIN 404 FUER SCOUTS OHNE CREATOR. + + Der Gedanke war: Wer hier nichts zu sehen hat, soll nicht einmal + erfahren, dass es den Bereich gibt. Das ging nach hinten los -- die + Seite ist ja verlinkt, jeder Scout kommt mit einem Klick hin. Er + landete dann auf einer Seite, die sich nicht aufbauen konnte, mit + einem Fehler in der Konsole und ohne ein Wort, woran es liegt. + + Ein Bereich, den eine Rolle SIEHT, muss auch funktionieren. Deshalb + antwortet der Report jetzt sauber und leer, statt sich zu verweigern: + Die Auswahl ist leer, die Zahlen sind leer, und die Seite kann sagen, + dass noch kein Creator zugeteilt ist. Zu sehen bekommt er dadurch + keine Zeile mehr als vorher -- zielCreator() gibt einem Scout ohne + Zuteilung weiterhin -1, und darauf passt kein Datensatz. */ +const ohneZuteilung = (person) => + person.rolle === "scout" && !betreuteIds(person).length; + reportRouter.use("/workspace/api/report", angemeldet); /* Auf welchen Creator schaut der Report? Das Management darf waehlen @@ -65,6 +77,11 @@ function zielCreator(req) { reportRouter.get("/workspace/api/report", (req, res) => { try { + /* Ohne zugeteilten Creator gibt es nichts auszuwerten. Das ist eine + Auskunft, kein Fehler -- die Seite baut daraus ihren Hinweis. */ + if (ohneZuteilung(req.person)) { + return res.json({ leer: true, grund: "keine_zuteilung", creator: null }); + } const tage = Math.min(Math.max(Number(req.query.tage) || 30, 7), 180); const creator = zielCreator(req); diff --git a/server/workspace-serien.js b/server/workspace-serien.js index 129b5076..25dcdf77 100644 --- a/server/workspace-serien.js +++ b/server/workspace-serien.js @@ -59,7 +59,7 @@ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, istLeitung, termineSichtbar, - heuteLokal, tagLokal, + heuteLokal, tagLokal, externPruefen, externSql, } from "./workspace.js"; export const serienRouter = express.Router(); @@ -265,15 +265,17 @@ export function serieNachfuellen(serieId) { const einfuegen = d.prepare(` INSERT INTO termine (titel, beschreibung, art, beginn, dauer_min, ort, creator_id, teilnehmer_id, - erledigt, erstellt, erstellt_von, serie_id, serie_tag, serie_beruehrt) - VALUES (?,?,?,?,?,?,?,?,0,?,?,?,?,0)`); + teilnehmer_extern, erledigt, erstellt, erstellt_von, serie_id, serie_tag, + serie_beruehrt) + VALUES (?,?,?,?,?,?,?,?,?,0,?,?,?,?,0)`); let neu = 0; const stempel = jetztStempel(); for (const tag of tage) { if (da.has(tag) || aus.has(tag)) continue; einfuegen.run(s.titel, s.beschreibung, s.art, `${tag}T${s.uhrzeit}`, s.dauer_min, - s.ort, s.creator_id, s.teilnehmer_id, stempel, s.erstellt_von, s.id, tag); + s.ort, s.creator_id, s.teilnehmer_id, s.teilnehmer_extern ?? null, + stempel, s.erstellt_von, s.id, tag); neu++; } return neu; @@ -385,6 +387,12 @@ export function serienPruefen(körper, { neu }) { else aus[feld] = z; } + /* Ein frei eingetragener Name statt einer Person -- gleiche Regel wie + beim einzelnen Termin, damit eine Wiederholung nicht plötzlich + etwas anderes kann als der Termin, aus dem sie entsteht. */ + externPruefen(körper, aus, "teilnehmer", fehler); + if (aus.teilnehmer_extern) aus.creator_id = null; + /* Ein Ende vor dem Anfang ergäbe eine Serie ohne einen einzigen Termin -- sie stünde in der Liste und täte nichts. */ const start = aus.start_tag ?? körper.start_tag; @@ -402,11 +410,12 @@ export function serieAnlegen(aus, person) { const { lastInsertRowid } = d.prepare(` INSERT INTO termin_serien (titel, beschreibung, art, takt, start_tag, uhrzeit, ende_tag, dauer_min, ort, - creator_id, teilnehmer_id, aktiv, erstellt, erstellt_von) - VALUES (?,?,?,?,?,?,?,?,?,?,?,1,?,?)`).run( + creator_id, teilnehmer_id, teilnehmer_extern, aktiv, erstellt, erstellt_von) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`).run( aus.titel, aus.beschreibung ?? null, aus.art ?? "termin", aus.takt, aus.start_tag, aus.uhrzeit, aus.ende_tag ?? null, aus.dauer_min ?? 30, aus.ort ?? null, - aus.creator_id ?? null, aus.teilnehmer_id ?? null, jetztStempel(), person.id); + aus.creator_id ?? null, aus.teilnehmer_id ?? null, aus.teilnehmer_extern ?? null, + jetztStempel(), person.id); const id = Number(lastInsertRowid); const angelegt = serieNachfuellen(id); @@ -424,6 +433,7 @@ export function zuordnungErzwingen(aus, person) { if (istLeitung(person)) return; aus.creator_id = person.rolle === "creator" ? person.id : null; aus.teilnehmer_id = person.id; + aus.teilnehmer_extern = null; } /* ---------- Schranken ----------------------------------------------------- */ @@ -469,7 +479,9 @@ serienRouter.get("/workspace/api/serien", (req, res) => { const person = req.sicht || req.person; const { wo, werte } = termineSichtbar(person, "s"); const roh = db().prepare(` - SELECT s.*, pc.name AS creator_name, pt.name AS teilnehmer_name + SELECT s.*, + ${externSql("pc.name", "s.teilnehmer_extern")} AS creator_name, + ${externSql("pt.name", "s.teilnehmer_extern")} AS teilnehmer_name FROM termin_serien s LEFT JOIN personen pc ON pc.id = s.creator_id LEFT JOIN personen pt ON pt.id = s.teilnehmer_id @@ -529,7 +541,9 @@ serienRouter.patch("/workspace/api/serien/:id", gleicheHerkunft, (req, res) => { const { aus, fehler } = serienPruefen(req.body || {}, { neu: false }); if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); - if (!istLeitung(req.person)) { delete aus.creator_id; delete aus.teilnehmer_id; } + if (!istLeitung(req.person)) { + delete aus.creator_id; delete aus.teilnehmer_id; delete aus.teilnehmer_extern; + } if (req.body?.aktiv !== undefined) aus.aktiv = req.body.aktiv ? 1 : 0; /* Auch gegen den BESTEHENDEN Start prüfen, nicht nur gegen einen diff --git a/server/workspace.js b/server/workspace.js index 0af60e19..bae6fb2f 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -194,6 +194,37 @@ function umstellungen(d) { ["termine", "serie_id", "INTEGER REFERENCES termin_serien(id) ON DELETE SET NULL"], ["termine", "serie_tag", "TEXT"], ["termine", "serie_beruehrt", "INTEGER NOT NULL DEFAULT 0"], + + /* ---- Frei eingetragene Namen (02.09.2026) ---- + Wunsch: "ich will da auch sachen selber noch eintragen können, + also aussuchen und selbst eintragen." + + Bis hierher konnte an einem Eintrag nur stehen, wer auch ein Konto + im Workspace hat. Eine Agentur, eine Marke, ein Gast liess sich + nicht eintragen -- man haette dafuer ein Konto anlegen muessen, + das nie jemand benutzt. + + Eine EIGENE Spalte je Feld, kein Missbrauch der id-Spalte: Ein + Fremdschluessel zeigt auf eine Person oder auf nichts. Einen + Namen dort hineinzuschreiben haette jede Verknuepfung zerstoert. + + Es gilt entweder/oder: Steht eine Person drin, ist das Feld leer, + und umgekehrt. Durchgesetzt wird das beim Speichern (siehe + externPruefen), nicht durch eine CHECK-Regel -- die liesse sich + an einer bestehenden Tabelle nicht mehr nachtragen. + + WICHTIG, damit nichts still verschwindet: Ueberall, wo bisher der + Name der verknuepften Person gelesen wurde, faellt die Abfrage + jetzt auf diesen Text zurueck (siehe externSql). Ein frei + eingetragener Name steht dadurch in jeder Liste, jeder Suche und + jeder Uebersicht mit da -- gekennzeichnet als "(extern)", damit + niemand ihn fuer ein Konto haelt. */ + ["termine", "teilnehmer_extern", "TEXT"], + ["termin_serien", "teilnehmer_extern", "TEXT"], + ["aufgaben", "creator_extern", "TEXT"], + ["aufgaben", "verantwortlich_extern", "TEXT"], + ["eintraege", "creator_extern", "TEXT"], + ["dateien", "creator_extern", "TEXT"], ]) { try { const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); @@ -1150,6 +1181,56 @@ export function betreutWo(person, spalte) { return { wo: `${spalte} IN (${ids.map(() => "?").join(",")})`, werte: ids }; } +/* ===================================================================== + FREI EINGETRAGENE NAMEN (02.09.2026) + + Ein Feld wie "Mit wem" oder "Creator" nimmt entweder eine Person aus + dem Workspace ODER einen frei getippten Namen. Beides zugleich waere + eine Aussage, die niemand aufloesen kann. + + WAS EIN FREIER NAME NICHT KANN, und warum das hier steht: + Er ist eine Beschriftung, kein Konto. Er sieht nichts, bekommt nichts + angezeigt, hat kein Aufgabenbrett. Bei "Creator" heisst das: Der + Eintrag gehoert zu niemandem im System und taucht deshalb in keiner + personenbezogenen Auswertung auf. Filipe hat das am 02.09.2026 + ausdruecklich so gewaehlt, nachdem der Nachteil benannt war; das + Formular sagt es an der Stelle noch einmal. + + Damit daraus kein STILLER Ausfall wird, ist die Gegenmassnahme + eingebaut: externSql. Jede Abfrage, die bisher den Namen der + verknuepften Person las, faellt jetzt auf den freien Text zurueck. + Der Eintrag verschwindet dadurch aus keiner Liste, keiner Suche und + keiner Uebersicht -- er ist nur eben als "(extern)" gekennzeichnet. + ===================================================================== */ + +export const EXTERN_MAX = 80; + +/** Prueft einen frei eingetragenen Namen und schreibt ihn nach `aus`. + * `feld` ist die Spalte ohne Endung, also "creator" oder "teilnehmer". + * + * Ist eine Person gewaehlt, wird der freie Name verworfen statt + * abgelehnt: Wer erst jemanden auswaehlt und dann tippt, meint das + * Getippte nicht mehr -- eine Fehlermeldung waere hier nur im Weg. */ +export function externPruefen(körper, aus, feld, fehler) { + const schluessel = `${feld}_extern`; + if (körper[schluessel] === undefined) return; + const t = String(körper[schluessel] ?? "").trim(); + if (!t) { aus[schluessel] = null; return; } + if (t.length > EXTERN_MAX) { fehler.push("Der eingetragene Name ist zu lang."); return; } + /* Steuerzeichen raus. Sie waeren unsichtbar und koennten eine Zeile + in Listen und Ausgaben zerreissen. */ + aus[schluessel] = t.replace(/[\u0000-\u001f\u007f]/g, ""); + aus[`${feld}_id`] = null; +} + +/** SQL-Baustein: der Name der Person -- und wenn keine verknuepft ist, + * der frei eingetragene Text, gekennzeichnet. + * + * In SQLite ergibt NULL || 'x' wieder NULL. Ist die Spalte leer, faellt + * COALESCE deshalb korrekt auf NULL durch und nicht auf " (extern)". */ +export const externSql = (personSpalte, externSpalte) => + `COALESCE(${personSpalte}, ${externSpalte} || ' (extern)')`; + /* Wer sieht welchen Termin -- und damit auch: welche Wiederholung. NUR DogFather sieht alles (01.09.2026). Vorher stand hier istLeitung() diff --git a/workspace/assets/css/aufgaben.css b/workspace/assets/css/aufgaben.css index d2ef4647..756f8366 100644 --- a/workspace/assets/css/aufgaben.css +++ b/workspace/assets/css/aufgaben.css @@ -465,3 +465,49 @@ } .schnitt, .creator-knopf { flex: none; } } + +/* =================================================================== + Der feste Call-Raum (02.09.2026) + + Eine ruhige Zeile über der Liste: wo die Gespräche stattfinden. Sie + beantwortet die Frage einmal, statt sie an jedem Termin neu zu + stellen. Bewusst zurückhaltend -- sie ist eine Auskunft, keine + Aufforderung. + =================================================================== */ +.call-raum { + display: flex; align-items: center; flex-wrap: wrap; gap: 10px 14px; + margin: 0 0 18px; padding: 11px 14px; + border: 1px solid var(--rand); border-radius: var(--radius-klein); + background: var(--flaeche); +} +.call-raum__zeile { + margin: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; + min-width: 0; +} +.call-raum__schild { + /* .78rem = 12,48 px. Untergrenze auf dem Handy sind 11,5 px (siehe + pruef-grosscheck.mjs) -- die vorher hier stehenden .68rem waren + 10,88 px und damit unter der Grenze. Eine Beschriftung in + Grossbuchstaben braucht ohnehin mehr Groesse, nicht weniger. */ + font-size: .78rem; letter-spacing: .09em; text-transform: uppercase; + color: var(--text-still); +} +.call-raum__link { + color: var(--akzent); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + max-width: min(60vw, 520px); +} +.call-raum__leer { color: var(--text-still); font-size: .86rem; } +.call-raum button { margin-left: auto; } +.call-raum__form { + display: flex; align-items: end; flex-wrap: wrap; gap: 8px; + width: 100%; +} +.call-raum__form .feld-schild { width: 100%; margin: 0 0 2px; } +.call-raum__form input { flex: 1 1 260px; min-width: 0; } +.call-raum__form button { margin-left: 0; } + +@media (max-width: 560px) { + .call-raum { align-items: flex-start; } + .call-raum button { margin-left: 0; } +} diff --git a/workspace/assets/css/gate.css b/workspace/assets/css/gate.css index 4451f2c6..be890d0c 100644 --- a/workspace/assets/css/gate.css +++ b/workspace/assets/css/gate.css @@ -803,9 +803,63 @@ body.suche-offen { overflow: hidden; } color: var(--text-still); cursor: default; } +/* ---------- Suchen und selbst eintragen (02.09.2026) -------------------- + Das Suchfeld sitzt fest oben in der Liste, die Eintraege scrollen + darunter weg -- sonst waere es beim Scrollen weg, und man wuesste + nicht mehr, wonach gerade gefiltert wird. */ +.wahl2__suche { + display: block; width: 100%; + margin: 0 0 5px; padding: 8px 11px; + box-sizing: border-box; + background: rgba(255, 255, 255, .04); + border: 1px solid var(--rand); border-radius: 8px; + color: var(--text); font: inherit; font-size: .88rem; +} +.wahl2__suche::placeholder { color: var(--text-still); } +.wahl2__suche:focus-visible { + outline: none; + border-color: rgba(63, 189, 245, .6); + box-shadow: 0 0 0 3px rgba(63, 189, 245, .14); +} +/* Nur die Eintraege scrollen, nicht das Suchfeld mit. */ +.wahl2__zeilen { max-height: min(46vh, 320px); overflow-y: auto; } + +/* Der getippte Name als eigener Eintrag. Gedeckt abgesetzt, nicht + grell -- er ist eine Moeglichkeit, keine Warnung. */ +.wahl2__eintrag--eigen { + color: #e8c07a; + border: 1px dashed rgba(216, 161, 58, .45); + margin-bottom: 4px; +} +.wahl2__eintrag--eigen:hover:not([disabled]) { + background: rgba(216, 161, 58, .12); color: #f4dcb0; +} +.wahl2__leer { + margin: 0; padding: 9px 11px; + color: var(--text-still); font-size: .82rem; +} + +/* Steht ein freier Name im Feld, sagt der Rand es leise mit. */ +.wahl2__knopf[data-extern="ja"] { + border-color: color-mix(in srgb, #d8a13a 45%, var(--rand)); +} +.wahl2__hinweis { + margin: 6px 0 0; + font-size: .78rem; line-height: 1.45; + color: var(--text-leise); +} +.wahl2__hinweis::before { + content: "↯"; margin-right: 6px; color: #d8a13a; +} + @media (prefers-reduced-motion: reduce) { .wahl2__knopf, .wahl2__pfeil { transition: none; } } +@media (forced-colors: active) { + /* Ohne Farben traegt der gestrichelte Rand die Unterscheidung. */ + .wahl2__eintrag--eigen { border: 1px dashed CanvasText; } + .wahl2__knopf[data-extern="ja"] { border: 2px solid CanvasText; } +} /* =================================================================== Schalter (31.08.2026). diff --git a/workspace/assets/css/kalender.css b/workspace/assets/css/kalender.css index 1972043c..72accb22 100644 --- a/workspace/assets/css/kalender.css +++ b/workspace/assets/css/kalender.css @@ -626,7 +626,11 @@ padding: 2px 9px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--sfarbe) 32%, transparent); color: var(--sfarbe); - font-size: .68rem; letter-spacing: .08em; text-transform: uppercase; + /* .78rem = 12,48 px. Auf dem Handy sind 11,5 px die Untergrenze + (pruef-grosscheck.mjs); .68rem wären 10,88 px. Aufgefallen ist es + an derselben Stelle im Call-Raum -- hier wäre es erst aufgefallen, + sobald jemand eine Wiederholung anlegt. */ + font-size: .78rem; letter-spacing: .08em; text-transform: uppercase; } .k-serie__zustand { margin-left: auto; diff --git a/workspace/assets/js/aufgaben.js b/workspace/assets/js/aufgaben.js index f96c796a..03f5c39d 100644 --- a/workspace/assets/js/aufgaben.js +++ b/workspace/assets/js/aufgaben.js @@ -498,6 +498,8 @@ if (LEITUNG.has(ich.rolle)) { daten.creator_id = $('f-creator').value || null; daten.verantwortlich_id = $('f-verant').value || null; + daten.creator_extern = $('f-creator').dataset.extern || null; + daten.verantwortlich_extern = $('f-verant').dataset.extern || null; } knopfEl.disabled = true; @@ -551,6 +553,8 @@ if (LEITUNG.has(ich.rolle)) { daten.creator_id = $('b-creator').value || null; daten.verantwortlich_id = $('b-verant').value || null; + daten.creator_extern = $('b-creator').dataset.extern || null; + daten.verantwortlich_extern = $('b-verant').dataset.extern || null; } $('b-speichern').disabled = true; try { @@ -609,6 +613,16 @@ Das laesst sich nicht rueckgaengig machen. Erledigte Aufgaben sollten normalerwe } } } catch { /* Zuordnung bleibt dann leer -- kein Grund abzubrechen */ } + /* Aussuchen ODER selbst eintragen (02.09.2026). Der Hinweis + erscheint erst, wenn wirklich ein freier Name drinsteht -- ein + dauerhafter Warnsatz wird nach zwei Tagen überlesen. */ + for (const ziel of ['f-creator', 'b-creator']) { + window.personenwahl?.(ziel, { hinweis: "Kein Konto im Workspace – der Eintrag gehört dann zu keiner Person und taucht in personenbezogenen Auswertungen nicht auf. Sichtbar bleibt er für dich." }); + } + for (const ziel of ['f-verant', 'b-verant']) { + window.personenwahl?.(ziel, { hinweis: "Kein Konto im Workspace – der Name steht nur an diesem Eintrag." + + ' Die Aufgabe steht dann auf keinem persönlichen Aufgabenbrett.' }); + } } await laden(); diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index 0c3ed905..448d03a9 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -295,7 +295,10 @@ }; if (einstellung.dringlichkeit) daten.dringlichkeit = $('f-dringlich').value; if (einstellung.bewertung) daten.bewertung = $('f-bewertung').value || null; - if (LEITUNG.has(ich.rolle)) daten.creator_id = $('f-creator').value || null; + if (LEITUNG.has(ich.rolle)) { + daten.creator_id = $('f-creator').value || null; + daten.creator_extern = $('f-creator').dataset.extern || null; + } knopf.disabled = true; try { @@ -373,6 +376,7 @@ $('f-creator').append(o); } } catch { /* Zuordnung bleibt leer */ } + window.personenwahl?.('f-creator', { hinweis: "Kein Konto im Workspace – der Eintrag gehört dann zu keiner Person und taucht in personenbezogenen Auswertungen nicht auf. Sichtbar bleibt er für dich." }); } /* Kommt jemand vom Bericht mit "nur die offenen", wird der Filter diff --git a/workspace/assets/js/calls.js b/workspace/assets/js/calls.js index 64956781..c9a85cf0 100644 --- a/workspace/assets/js/calls.js +++ b/workspace/assets/js/calls.js @@ -10,6 +10,12 @@ 'use strict'; const $ = (id) => document.getElementById(id); + /* Wer den festen Call-Raum ändern darf. Dieselbe Zweiteilung wie + überall sonst: DogFather und Manager entscheiden, alle anderen + benutzen. Der Server prüft es noch einmal -- das hier ist nur die + Anzeige. */ + const LEITUNG = new Set(['admin', 'manager']); + let ich = null; let daten = null; let offen = new Set(); @@ -43,6 +49,21 @@ } catch { return false; } } + /** Läuft dieses Gespräch gerade noch? Ab dem geplanten Beginn bis eine + * Stunde nach dem geplanten Ende. Gerechnet wird auf den Textformen + * ('JJJJ-MM-TTThh:mm'), die der Server ohnehin schickt -- sie + * sortieren sich richtig und brauchen keine Zeitzone. */ + function laeuftNoch(c) { + if (!daten?.jetzt || !c.beginn) return false; + const ende = new Date(c.beginn.replace(' ', 'T') + ':00'); + if (Number.isNaN(ende.getTime())) return false; + ende.setMinutes(ende.getMinutes() + (c.dauer_min || 30) + 60); + const p = (n) => String(n).padStart(2, '0'); + const endeText = `${ende.getFullYear()}-${p(ende.getMonth() + 1)}-${p(ende.getDate())}` + + `T${p(ende.getHours())}:${p(ende.getMinutes())}`; + return c.beginn <= daten.jetzt && daten.jetzt <= endeText; + } + 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'); } @@ -231,17 +252,32 @@ if (c.beschreibung) k.append(el('p', 'call__text', c.beschreibung)); - /* Meeting-Link nur bei anstehenden Gesprächen -- danach ist er wertlos. */ + /* WO FINDET DER CALL STATT? (02.09.2026) + + Vorher gab es hier nur dann einen Knopf, wenn jemand von Hand + einen Link in das Ortsfeld getippt hatte -- und nur, solange das + Gespräch noch bevorstand. Stand dort "Hier" oder "Discord", war + nichts zum Anklicken da, und wer beitreten wollte, musste selbst + wissen wohin. Genau das war die Frage: "wo führe ich den Call?" + + Jetzt gilt: der eigene Ort des Termins, wenn er ein Link ist -- + sonst der feste Call-Raum des Teams. Und der Knopf bleibt, solange + das Gespräch laufen kann, nicht nur bis zur Startzeit: Wer sich + verspätet, braucht ihn genau dann. */ const knöpfe = el('div', 'kk__knoepfe'); - if (gruppe === 'anstehend' && istLink(c.ort)) { - const a = el('a', 'knopf knopf--klein', 'Call öffnen'); - a.href = c.ort; + const ziel = istLink(c.ort) ? c.ort : (daten.call_link || ''); + if (ziel && (gruppe === 'anstehend' || laeuftNoch(c))) { + const a = el('a', 'knopf knopf--klein', laeuftNoch(c) ? 'Call beitreten' : 'Call öffnen'); + a.href = ziel; a.target = '_blank'; a.rel = 'noopener noreferrer'; + /* Steht der Raum nicht am Termin, sondern kommt aus den + Einstellungen, gehört das dazu -- sonst wundert man sich, wohin + der Knopf führt. */ + if (!istLink(c.ort)) a.title = 'Fester Call-Raum des Teams'; knöpfe.append(a); - } else if (c.ort && !istLink(c.ort)) { - k.append(el('p', 'call__ort', c.ort)); } + if (c.ort && !istLink(c.ort)) k.append(el('p', 'call__ort', c.ort)); if (gruppe !== 'anstehend') { const b = el('button', gruppe === 'offen' ? 'knopf knopf--klein' : 'knopf-still', @@ -346,10 +382,64 @@ const a = await hole('/workspace/api/calls'); if (!a.ok) { melde('Gespräche konnten nicht geladen werden.'); return; } daten = await a.json(); + raumZeigen(); zeichne(); } catch { /* umgeleitet */ } } + /* ---------- Der feste Call-Raum ------------------------------------------ + Sichtbar für alle, änderbar nur vom Management. Wer nicht ändern + darf und wo noch nichts hinterlegt ist, bekommt die Zeile gar nicht + zu sehen -- eine leere Angabe, an der man nichts tun kann, ist nur + Rauschen. */ + + const darfRaum = () => LEITUNG.has(ich?.rolle); + + function raumZeigen() { + const link = daten?.call_link || ''; + const block = $('call-raum'); + block.hidden = !link && !darfRaum(); + $('call-raum-aendern').hidden = !darfRaum(); + $('call-raum-link').hidden = !link; + $('call-raum-leer').hidden = !!link; + if (link) { + $('call-raum-link').href = link; + /* Der nackte Link ist unlesbar lang. Angezeigt wird, woran man ihn + erkennt -- die volle Adresse steht im Titel und im Verweis. */ + $('call-raum-link').textContent = link.replace(/^https?:\/\//i, '').slice(0, 60); + $('call-raum-link').title = link; + } + } + + $('call-raum-aendern').addEventListener('click', () => { + $('call-raum-feld').value = daten?.call_link || ''; + $('call-raum-form').hidden = false; + $('call-raum-aendern').hidden = true; + $('call-raum-feld').focus(); + }); + $('call-raum-abbrechen').addEventListener('click', () => { + $('call-raum-form').hidden = true; + $('call-raum-aendern').hidden = false; + }); + $('call-raum-form').addEventListener('submit', async (e) => { + e.preventDefault(); + melde(''); + try { + const a = await hole('/workspace/api/call-link', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ link: $('call-raum-feld').value.trim() }), + }); + if (!a.ok) { + melde((await a.json().catch(() => ({}))).fehler || 'Speichern ging nicht.'); + return; + } + $('call-raum-form').hidden = true; + $('call-raum-aendern').hidden = false; + await laden(); + } catch { /* umgeleitet */ } + }); + /* ---------- Start --------------------------------------------------------- */ (async () => { diff --git a/workspace/assets/js/content.js b/workspace/assets/js/content.js index e340f85d..9e87d374 100644 --- a/workspace/assets/js/content.js +++ b/workspace/assets/js/content.js @@ -496,7 +496,10 @@ text: $('f-text').value.trim(), datum: window.heuteLokal(), }; - if (LEITUNG.has(ich.rolle)) daten.creator_id = $('f-creator').value || null; + if (LEITUNG.has(ich.rolle)) { + daten.creator_id = $('f-creator').value || null; + daten.creator_extern = $('f-creator').dataset.extern || null; + } const a = await hole('/workspace/api/bereich/content', { method: 'POST', @@ -576,6 +579,11 @@ } } } catch { /* Auswahl bleibt leer */ } + window.personenwahl?.('f-creator', { hinweis: "Kein Konto im Workspace – der Eintrag gehört dann zu keiner Person und taucht in personenbezogenen Auswertungen nicht auf. Sichtbar bleibt er für dich." }); + /* Bei den Saeulen KEIN freier Name: Eine Content-Saeule gehoert + immer genau einem Creator, der Server prueft das. Ein freier + Name waere dort eine Eingabe, die garantiert abgelehnt wird. */ + window.personenwahl?.('s-creator', { frei: false }); /* Wechselt der Creator, passen die Säulen dazu -- sonst böte die Liste Säulen an, die zu jemand anderem gehören, und der Server lehnte beim Speichern ab. */ diff --git a/workspace/assets/js/dateien.js b/workspace/assets/js/dateien.js index 23f40d85..fd2b3224 100644 --- a/workspace/assets/js/dateien.js +++ b/workspace/assets/js/dateien.js @@ -107,6 +107,10 @@ 'X-Dateiname': encodeURIComponent(datei.name), }; if (LEITUNG.has(ich.rolle) && $('f-creator').value) kopf['X-Creator'] = $('f-creator').value; + /* Der freie Name geht URL-kodiert mit -- Kopfzeilen sind streng + genommen nur ASCII, "Müller" käme sonst verstümmelt an. */ + const externName = $('f-creator')?.dataset.extern || ''; + if (externName) kopf['X-Creator-Extern'] = encodeURIComponent(externName); /* Wer die Datei sehen darf -- als Liste von Ids im Kopf. */ const gewaehlt = [...document.querySelectorAll('.wahl__knopf[data-an="ja"]')] .map((b) => b.dataset.id); @@ -403,6 +407,7 @@ $('f-creator').append(o); } } catch { /* Zuordnung bleibt leer */ } + window.personenwahl?.('f-creator', { hinweis: "Kein Konto im Workspace – der Eintrag gehört dann zu keiner Person und taucht in personenbezogenen Auswertungen nicht auf. Sichtbar bleibt er für dich." }); } else if (ich.rolle === 'scout') { $('zuordnung').hidden = false; $('f-creator').closest('.feld').hidden = true; diff --git a/workspace/assets/js/kalender.js b/workspace/assets/js/kalender.js index 32ba70ad..41f2a25a 100644 --- a/workspace/assets/js/kalender.js +++ b/workspace/assets/js/kalender.js @@ -1098,6 +1098,9 @@ const wahl = $('f-teilnehmer').value; koerper.teilnehmer_id = wahl || null; koerper.creator_id = wahl || null; + /* Ein frei eingetragener Name statt einer Person. Der Server + raeumt dann creator_id ab -- siehe workspace-kalender.js. */ + koerper.teilnehmer_extern = $('f-teilnehmer').dataset.extern || null; } /* Mit Rhythmus entsteht keine einzelne Zeile, sondern eine Regel -- der Server legt daraus die Termine an, den ersten eingeschlossen. @@ -1154,6 +1157,7 @@ $('f-teilnehmer').append(o); } } catch { /* Zuordnung bleibt leer */ } + window.personenwahl?.('f-teilnehmer', { hinweis: "Kein Konto im Workspace – der Name steht nur an diesem Eintrag." }); } /* Vom Bericht kommend: VOR dem ersten Laden auf die Liste stellen, diff --git a/workspace/assets/js/profil.js b/workspace/assets/js/profil.js index b37e7374..20ee57ea 100644 --- a/workspace/assets/js/profil.js +++ b/workspace/assets/js/profil.js @@ -110,9 +110,19 @@ if (LEITUNG.has(ich.rolle) || ich.rolle === 'scout') { if (!liste.creator.length) { - $('unterzeile').textContent = ich.rolle === 'scout' + /* Der Hinweis stand bis zum 02.09.2026 NUR in der Unterzeile -- + einer grauen Zeile unter der Überschrift, die man beim + Überfliegen für Beiwerk hält. Darunter blieb die Seite leer, + und leer sieht aus wie kaputt. Der Satz gehört auch dorthin, + wo man den Inhalt erwartet. */ + const satz = ich.rolle === 'scout' ? 'Dir ist noch kein Creator zugeteilt. DogFather legt fest, wen du betreust.' : 'Es gibt noch keinen Creator. Lege zuerst unter Personen & Zugänge einen an.'; + $('unterzeile').textContent = satz; + const hinweis = document.createElement('p'); + hinweis.className = 'leer-hinweis'; + hinweis.textContent = satz; + document.querySelector('main')?.append(hinweis); return; } if (ich.rolle === 'scout') { diff --git a/workspace/assets/js/report.js b/workspace/assets/js/report.js index f687b8e9..ad0cb6af 100644 --- a/workspace/assets/js/report.js +++ b/workspace/assets/js/report.js @@ -303,8 +303,21 @@ /* Ein Scout waehlt zwischen SEINEN Creator -- "alle" gibt es fuer ihn nicht, das waere die Gesamtuebersicht. */ if (!creator.length) { - $('unterzeile').textContent = - 'Dir ist noch kein Creator zugeteilt. DogFather legt fest, wen du betreust.'; + /* Nicht nur die Unterzeile beschriften: Der Berichtsbereich + stand danach für immer auf "Report wird erstellt …", weil + ihn nichts mehr anfasste. Für ein Vorleseprogramm ist eine + dauerhaft beschäftigte Fläche das Zeichen, hier nicht + weiterzulesen. Gefunden beim Rollen-Rundgang 02.09.2026. */ + const satz = 'Dir ist noch kein Creator zugeteilt. ' + + 'DogFather legt fest, wen du betreust.'; + $('unterzeile').textContent = satz; + const ziel = $('bericht'); + ziel.setAttribute('aria-busy', 'false'); + ziel.textContent = ''; + const hinweis = document.createElement('p'); + hinweis.className = 'leer-hinweis'; + hinweis.textContent = satz; + ziel.append(hinweis); return; } $('creator-block').hidden = false; diff --git a/workspace/assets/js/startcheck.js b/workspace/assets/js/startcheck.js index 018e5d4c..0c3d76f8 100644 --- a/workspace/assets/js/startcheck.js +++ b/workspace/assets/js/startcheck.js @@ -275,6 +275,13 @@ if (!liste.creator.length) { $('felder').textContent = ''; + /* aria-busy zurücksetzen, sonst meldet die Seite für immer "wird + geladen" -- an einer Stelle, an der gerade fertig geladen und + festgestellt wurde, dass es nichts zu laden gibt. Für ein + Vorleseprogramm ist eine dauerhaft beschäftigte Fläche das + Zeichen, dass es sich nicht lohnt weiterzulesen; gefunden beim + Rollen-Rundgang am 02.09.2026. */ + $('felder').setAttribute('aria-busy', 'false'); $('felder').append(el('p', 'leer-hinweis', ich.rolle === 'scout' ? 'Dir ist noch kein Creator zugeteilt. DogFather legt fest, wen du betreust.' : 'Es gibt noch keinen Creator.')); diff --git a/workspace/assets/js/wahl.js b/workspace/assets/js/wahl.js index 4607dd9d..3ca81b6c 100644 --- a/workspace/assets/js/wahl.js +++ b/workspace/assets/js/wahl.js @@ -21,6 +21,33 @@ Die Liste haengt an `position: fixed`, nicht am Elternelement: In einer Karte mit `overflow: hidden` waere sie sonst abgeschnitten. + + --------------------------------------------------------------------- + SUCHEN UND SELBST EINTRAGEN (02.09.2026) + + Wunsch: "ich will da auch sachen selber noch eintragen können, also + aussuchen und selbst eintragen." + + Zwei Dinge kamen dafuer dazu, und beide sitzen HIER und nicht in einem + zweiten Bauteil daneben. Der erste Anlauf war genau das -- eine eigene + Personenwahl neben dieser hier. Sie hat sich prompt mit ihr gebissen: + Zwei Bauteile haben denselben ist leer -- entweder eine Person ODER ein Name, nie + beides. Die Seiten schicken beides getrennt zum Server. + + Ohne Erlaubnis bleibt alles beim Alten: filtern ja, erfinden nein. =================================================================== */ (() => { 'use strict'; @@ -28,6 +55,11 @@ const OFFEN = new WeakMap(); let aktiv = null; // gerade offene Liste + /* Ab wann ein Suchfeld erscheint. Bei drei Eintraegen waere es nur im + Weg, bei siebzehn ist Scrollen die falsche Bedienung. */ + const SUCHE_AB = 8; + const EXTERN_MAX = 80; + const el = (tag, klasse, text) => { const k = document.createElement(tag); if (klasse) k.className = klasse; @@ -83,58 +115,163 @@ } function zeige(z) { + const extern = z.feld.dataset.extern || ''; const gewaehlt = z.feld.selectedOptions[0]; - z.text.textContent = gewaehlt ? gewaehlt.textContent : ''; - z.knopf.dataset.leer = gewaehlt && gewaehlt.value ? 'nein' : 'ja'; - z.knopf.disabled = z.feld.disabled || !z.feld.options.length; + /* Ein frei eingetragener Name schlaegt die Auswahl -- das , kennt aber den frei + eingetragenen Namen nicht -- ohne das hier stuende er nach dem + Abbrechen noch da und behauptete eine Zuordnung, die es nicht mehr + gibt. */ + document.addEventListener('reset', (e) => { + const felder = e.target?.querySelectorAll?.('select[data-extern]'); + if (!felder) return; + setTimeout(() => { + for (const f of felder) { + f.dataset.extern = ''; + const z = OFFEN.get(f); + if (z) zeige(z); + } + }, 0); + }); + /* ---------- Aufwerten, jetzt und spaeter --------------------------------- */ const alle = () => document.querySelectorAll('select:not(.wahl2__echt)'); @@ -202,4 +369,39 @@ new MutationObserver(() => laufen()).observe(document.documentElement, { childList: true, subtree: true, }); + + /* ---------- Freie Namen erlauben ----------------------------------------- + + Die Seiten rufen das auf, nachdem sie ihre Personen geladen haben: + + window.personenwahl?.('f-creator', { hinweis: '…' }); + + Danach nimmt genau dieses Feld auch einen getippten Namen an. Der + Hinweis steht unter dem Feld, sobald wirklich einer eingetragen ist. + + Als Attribute am Feld und nicht als Zustand hier drinnen: So + ueberlebt die Einstellung, wenn die Seite das Feld spaeter neu + aufbaut -- und man sieht ihr im Browser-Werkzeug an, dass sie + gesetzt ist. */ + window.personenwahl = function (feld, optionen = {}) { + const f = typeof feld === 'string' ? document.getElementById(feld) : feld; + if (!f) return null; + f.dataset.frei = optionen.frei === false ? 'nein' : 'ja'; + if (optionen.hinweis) f.dataset.freiHinweis = optionen.hinweis; + if (!f.dataset.extern) f.dataset.extern = ''; + /* Falls das Feld schon aufgewertet ist, den Knopf neu beschriften -- + "disabled" haengt jetzt an einer anderen Bedingung. */ + const z = OFFEN.get(f); + if (z) zeige(z); + return { + wert: () => f.value, + extern: () => f.dataset.extern || '', + zuruecksetzen() { + f.value = ''; + f.dataset.extern = ''; + const zz = OFFEN.get(f); + if (zz) zeige(zz); + }, + }; + }; })(); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index c1a18dea..1f725f05 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -152,9 +152,9 @@ - - - - + + + + diff --git a/workspace/automation.html b/workspace/automation.html index bac1f592..08b650d0 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -132,9 +132,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 0907b1de..1c750919 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -115,13 +115,13 @@ - - - - - - - - + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 2ee9ccfd..2a4ed7bc 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -48,16 +48,36 @@ Call planen + + +

Gespräche werden geladen …

- - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index b8690438..67473515 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -164,11 +164,11 @@ - - - - - - + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index b7356dd3..be85d9cb 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -93,9 +93,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index debe5dbd..ec253e70 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -25,7 +25,7 @@ type="image/webp" media="(max-width: 899px)" /> - + @@ -143,6 +143,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index e507f89a..9266e71e 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -178,9 +178,9 @@ - - - - + + + + diff --git a/workspace/personen.html b/workspace/personen.html index e4bb69fd..8a4ab33c 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -121,9 +121,9 @@ - - - - + + + + diff --git a/workspace/profil.html b/workspace/profil.html index ef6698ea..3fa208a1 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -193,10 +193,10 @@ - - - - - + + + + + diff --git a/workspace/report.html b/workspace/report.html index c2728341..07a1c9e0 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -91,10 +91,10 @@ - - - - - + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index df4cc667..b779e82a 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,9 +124,9 @@ - - - - + + + + diff --git a/workspace/start.html b/workspace/start.html index e39f54b5..4b87f67f 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -99,9 +99,9 @@

- - - - + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 055a87b3..b8c00536 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -70,10 +70,10 @@ - - - - - + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 05a8df7d..98032152 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -111,9 +111,9 @@ - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 093080bb..c3255f61 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -7,9 +7,9 @@ - - - + + + @@ -57,9 +57,9 @@ - + - - + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 4dad148a..d169cfe7 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -175,9 +175,9 @@ - - - - + + + +