Workspace: Dashboard gebaut, Kopfleiste auf dem Handy repariert
Die Kachel "Dashboard" fuehrte nirgendwohin -- man stand ja schon auf der
Startseite. Statt sie zu streichen bekommt sie ein Ziel: die
Gesamtuebersicht je Creator, die das Konzept auf Seite 1 als "UEBERSICHT
-- alles zentral" verspricht und die es bisher nirgends gab. Die
Startseite bleibt der Einstieg (was ist dran, wohin gehe ich), das
Dashboard ist der Ueberblick (wie steht es um wen).
Eine Karte je Creator: ueberfaellig, dringend, offen, in Arbeit, im
Review, erledigt in 30 Tagen, dazu der naechste Termin, der Start-Check
als Balken und wie lange die Person nicht mehr da war. Jede Zahl ist ein
Verweis -- eine Zahl ohne Weg dorthin waere nur ein schlechtes Gewissen.
Sortiert nach dem, was Handlung verlangt, nicht alphabetisch.
Sichtbarkeit serverseitig wie ueberall: Creator sieht sich selbst
("Mein Stand"), Scout nur seine zugeteilten, DogFather und Manager alle.
Der naechste Review-Termin faellt fuer alle ausser der Leitung weg.
Nebenbei zwei alte Fehler gefunden und behoben:
1. Die Kopfleiste schob auf einem 390px-Handy 214 px aus dem Bild -- der
Abmelden-Knopf war dort auf JEDER Seite unerreichbar. Ursache: In der
Flex-Zeile fehlte min-width: 0, also konnte nichts schrumpfen. Auf
kleinen Schirmen weicht jetzt die Marke (steht direkt darunter noch
einmal als Ueberschrift) und der Zurueck-Knopf zeigt nur den Pfeil.
Geprueft von 320 bis 1440 px auf 14 Seiten.
2. .inhalt--breit, .kopf-zeile, .zurueck und .leise standen in
aufgaben.css -- einer Datei, die eine neue Seite gar nicht laden muss.
Derselbe Fehler wie frueher bei .knopf-still und .schalter. Jetzt in
start.css, die jede Seite laedt.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 247 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
@@ -1,251 +0,0 @@
|
||||
/* =====================================================================
|
||||
pruef-uebersicht.mjs — die neue Startansicht der Verwaltung
|
||||
|
||||
Zwei Dinge werden hier geprüft, und beide haben eine Vorgeschichte:
|
||||
|
||||
1. STEHEN DIE REITER WIRKLICH UNTER DEM TITEL?
|
||||
Das ist der ausdrückliche Wunsch vom 23.08.2026. Es wäre bequem,
|
||||
dafür das Vorhandensein einer CSS-Regel zu prüfen — das beweist
|
||||
aber nichts. Am 22.08.2026 waren 37 von 37 Prüfungen grün, während
|
||||
jede Aufgabenzeile im Browser verkehrt herum stand: Die Regeln
|
||||
waren da, das Ergebnis war falsch. Deshalb wird hier die
|
||||
tatsächliche Lage im Fenster gemessen — der Reiter MUSS weiter
|
||||
unten liegen als der Titel.
|
||||
|
||||
2. FÜHREN DIE KACHELN AUCH WIRKLICH IRGENDWOHIN?
|
||||
Eine Kachel, die aussieht wie ein Knopf und keiner ist, ist
|
||||
schlimmer als gar kein Knopf. Also wird geklickt und geprüft, dass
|
||||
danach die richtige Ansicht offen ist.
|
||||
===================================================================== */
|
||||
import { chromium } from "playwright";
|
||||
import { createServer } from "node:http";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join, extname } from "node:path";
|
||||
|
||||
const W = process.cwd();
|
||||
const T = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
|
||||
".webp": "image/webp", ".ico": "image/x-icon", ".json": "application/json" };
|
||||
const srv = createServer((q, r) => {
|
||||
const p = join(W, decodeURIComponent(q.url.split("?")[0]));
|
||||
if (existsSync(p) && !p.endsWith("/")) {
|
||||
r.writeHead(200, { "content-type": T[extname(p)] || "text/plain" });
|
||||
return r.end(readFileSync(p));
|
||||
}
|
||||
r.writeHead(404).end("");
|
||||
});
|
||||
await new Promise((r) => srv.listen(4337, r));
|
||||
|
||||
const ergebnisse = [];
|
||||
function pruefe(name, ok, zusatz) {
|
||||
ergebnisse.push({ name, ok });
|
||||
console.log((ok ? " ok " : " FEHL ") + name + (zusatz !== undefined ? " -> " + zusatz : ""));
|
||||
}
|
||||
|
||||
const vorTagen = (n) => new Date(Date.now() - n * 864e5).toISOString();
|
||||
|
||||
/* Absichtlich unbequeme Testdaten: eine Anfrage, die seit acht Tagen
|
||||
liegt (muss rot werden), eine Nachricht seit drei Tagen (gelb), und
|
||||
ein offener Widerruf von heute (muss trotzdem rot sein, weil eine
|
||||
gesetzliche Frist läuft). Ein Datensatz, in dem alles in Ordnung ist,
|
||||
würde die Warnstufen gar nicht erst auslösen. */
|
||||
const UEBERSICHT = {
|
||||
ok: true,
|
||||
beiMir: {
|
||||
anfragen: { n: 3, aeltestes: vorTagen(8) },
|
||||
nachrichten: { n: 2, aeltestes: vorTagen(3) },
|
||||
wuensche: { n: 0, aeltestes: null },
|
||||
widerrufe: { n: 1, aeltestes: vorTagen(0) },
|
||||
projekte: 2,
|
||||
},
|
||||
beiKunde: {
|
||||
projekte: 1,
|
||||
aufgaben: 4,
|
||||
zahlungen: { n: 2, summe: 120000, aeltestes: vorTagen(5) },
|
||||
zahlungenText: "1.200,00 €",
|
||||
},
|
||||
geld: {
|
||||
monat: { n: 3, brutto: 250000, netto: 241000, bruttoText: "2.500,00 €", nettoText: "2.410,00 €" },
|
||||
gesamt: { n: 11, brutto: 980000, bruttoText: "9.800,00 €" },
|
||||
gebuehrenText: "90,00 €",
|
||||
},
|
||||
projekte: [
|
||||
{ id: "p1", nummer: "P-2026-001", titel: "Website für Musterbau", status: "bau",
|
||||
wartet_auf: "dogfather", richttermin: "2026-09-15", kunde_name: "Anna Muster",
|
||||
aufg_gesamt: 20, aufg_fertig: 13 },
|
||||
{ id: "p2", nummer: "P-2026-002", titel: "Shop für Beispiel GmbH", status: "bau",
|
||||
wartet_auf: "kunde", richttermin: null, kunde_name: "Bert Beispiel",
|
||||
aufg_gesamt: 18, aufg_fertig: 4 },
|
||||
],
|
||||
verlauf: [
|
||||
{ bereich: "projekt", aktion: "Aufgabe erledigt: Startseite gebaut", wer: "dogfather", erstellt_am: vorTagen(0) },
|
||||
{ bereich: "zahlung", aktion: "Zahlung eingegangen", wer: "paypal", erstellt_am: vorTagen(1) },
|
||||
],
|
||||
bestand: { kunden: 5, projekte: 2, anfragenGesamt: 9 },
|
||||
paypal: { eingerichtet: true, live: true },
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
for (const [wo, breite, hoehe] of [["Computer", 1440, 950], ["Handy", 390, 844]]) {
|
||||
const seite = await browser.newPage({ viewport: { width: breite, height: hoehe } });
|
||||
const fehlerImProtokoll = [];
|
||||
seite.on("pageerror", (e) => fehlerImProtokoll.push(e.message));
|
||||
|
||||
await seite.route("**/webdesign/admin/**", (route) => {
|
||||
const u = new URL(route.request().url()).pathname;
|
||||
if (u.endsWith("/admin/uebersicht")) return route.fulfill({ json: UEBERSICHT });
|
||||
if (u.includes("/admin/postfach-zaehler")) return route.fulfill({ json: { ok: true, ungelesen: 2 } });
|
||||
if (u.includes("/admin/anfragen")) return route.fulfill({ json: { ok: true, anfragen: [] } });
|
||||
if (u.includes("/admin/kunden")) return route.fulfill({ json: { ok: true, kunden: [] } });
|
||||
if (u.includes("/admin/projekte")) return route.fulfill({ json: { ok: true, projekte: [] } });
|
||||
if (u.includes("/admin/zahlungen")) return route.fulfill({ json: { ok: true, zahlungen: [] } });
|
||||
if (u.includes("/admin/postfach")) return route.fulfill({ json: { ok: true, ungelesen: 2, verlaeufe: [] } });
|
||||
return route.fulfill({ json: { ok: true } });
|
||||
});
|
||||
await seite.route("**/auth/**", (r) => r.fulfill({ json: { ok: true, user: { name: "Test" } } }));
|
||||
await seite.addInitScript(() => {
|
||||
sessionStorage.setItem("wd-vw-token", "t");
|
||||
sessionStorage.setItem("wd-sitzung-aktiv", "1");
|
||||
});
|
||||
|
||||
await seite.goto("http://127.0.0.1:4337/webdesign/verwaltung.html");
|
||||
await seite.waitForTimeout(900);
|
||||
console.log("\n=== " + wo + " ===");
|
||||
|
||||
/* ---- 1. Die Verwaltung startet in der Übersicht ---- */
|
||||
const titel = await seite.locator("#vw-titel").innerText();
|
||||
pruefe(wo + ": die Verwaltung öffnet in der Übersicht", titel.trim() === "Übersicht", titel);
|
||||
pruefe(wo + ": das Cockpit ist sichtbar",
|
||||
await seite.locator("#vw-ub-inhalt").isVisible());
|
||||
pruefe(wo + ": die Anfragenliste liegt NICHT darunter",
|
||||
!(await seite.locator("#vw-liste").isVisible()));
|
||||
|
||||
/* ---- 2. Reiter UNTER dem Titel — gemessen, nicht behauptet ---- */
|
||||
const lage = await seite.evaluate(() => {
|
||||
const t = document.getElementById("vw-titel").getBoundingClientRect();
|
||||
/* Gemessen wird der erste KNOPF, nicht der Streifen um ihn herum.
|
||||
|
||||
Auf dem Handy sind die Reiter ein Wischstreifen, der bewusst 4px
|
||||
ueber den Containerrand hinauslaeuft -- sonst endet er optisch
|
||||
mitten auf der Seite, obwohl es weitergeht. Sichtbar ausgerichtet
|
||||
ist aber der Knopf, und nur der zaehlt fuer das Auge. */
|
||||
const r = document.querySelector(".vw-tabs [data-tab]").getBoundingClientRect();
|
||||
return { titelUnten: t.bottom, titelLinks: t.left, reiterOben: r.top, reiterLinks: r.left };
|
||||
});
|
||||
pruefe(wo + ": die Reiter beginnen UNTERHALB des Titels",
|
||||
lage.reiterOben >= lage.titelUnten - 1,
|
||||
"Titel endet bei " + Math.round(lage.titelUnten) + ", Reiter beginnen bei " + Math.round(lage.reiterOben));
|
||||
/* Zusatzbedingung, sonst wäre "darunter" auch erfüllt, wenn die Reiter
|
||||
weit nach rechts eingerückt stünden statt am Seitenrand zu beginnen. */
|
||||
pruefe(wo + ": die Reiter beginnen am selben linken Rand wie der Titel",
|
||||
Math.abs(lage.reiterLinks - lage.titelLinks) < 3,
|
||||
"Titel " + Math.round(lage.titelLinks) + " vs. Reiter " + Math.round(lage.reiterLinks));
|
||||
|
||||
/* ---- 2b. Groessenverhaeltnisse ----
|
||||
Eine Zwischenueberschrift darf nicht groesser sein als die Zahl, um
|
||||
die es geht. Genau das war der Fall, solange die Regel .wd h2 die
|
||||
eigene ueberstimmte: 44px Ueberschrift ueber 33px Zahl -- die Seite
|
||||
las sich wie ein Plakat statt wie ein Arbeitsplatz. */
|
||||
const groessen = await seite.evaluate(() => ({
|
||||
h: parseFloat(getComputedStyle(document.querySelector(".vw-ub-h")).fontSize),
|
||||
zahl: parseFloat(getComputedStyle(document.querySelector(".vw-ub-zahl")).fontSize),
|
||||
}));
|
||||
pruefe(wo + ": die Zwischenueberschrift ist kleiner als die Zahl darunter",
|
||||
groessen.h < groessen.zahl,
|
||||
"Ueberschrift " + groessen.h + "px, Zahl " + groessen.zahl + "px");
|
||||
|
||||
/* ---- 3. Inhalt der Kacheln ---- */
|
||||
const kacheln = await seite.locator("#vw-ub-beimir .vw-ub-kachel").allInnerTexts();
|
||||
pruefe(wo + ": alle fünf Kacheln 'bei mir' sind da (inkl. Widerruf)",
|
||||
kacheln.length === 5, kacheln.length);
|
||||
pruefe(wo + ": die drei offenen Anfragen stehen drin",
|
||||
kacheln.some((k) => k.includes("3") && k.includes("offene Anfragen")));
|
||||
pruefe(wo + ": das Alter steht als TEXT dabei, nicht nur als Farbe",
|
||||
kacheln.some((k) => k.includes("seit 8 Tagen")),
|
||||
kacheln.find((k) => k.includes("Anfragen")));
|
||||
|
||||
/* ---- 4. Die Warnstufen greifen wirklich ---- */
|
||||
const stufen = await seite.evaluate(() => {
|
||||
const raus = {};
|
||||
document.querySelectorAll("#vw-ub-beimir .vw-ub-kachel").forEach((k) => {
|
||||
const t = (k.querySelector(".vw-ub-titel") || {}).textContent || "";
|
||||
raus[t] = k.className;
|
||||
});
|
||||
return raus;
|
||||
});
|
||||
pruefe(wo + ": 8 Tage alte Anfrage ist rot",
|
||||
/vw-ub-kachel--rot/.test(stufen["offene Anfragen"] || ""), stufen["offene Anfragen"]);
|
||||
pruefe(wo + ": 3 Tage alte Nachricht ist nur gelb, nicht rot",
|
||||
/--warn/.test(stufen["ungelesene Nachrichten"] || "") &&
|
||||
!/--rot/.test(stufen["ungelesene Nachrichten"] || ""), stufen["ungelesene Nachrichten"]);
|
||||
pruefe(wo + ": ein Widerruf von HEUTE ist trotzdem rot (Frist läuft)",
|
||||
/--rot/.test(stufen["Widerrufe unbearbeitet"] || ""), stufen["Widerrufe unbearbeitet"]);
|
||||
pruefe(wo + ": eine Null-Kachel nimmt sich zurück statt zu warnen",
|
||||
/--leer/.test(stufen["Änderungswünsche zu prüfen"] || ""),
|
||||
stufen["Änderungswünsche zu prüfen"]);
|
||||
|
||||
/* ---- 5. Geld ---- */
|
||||
const geld = (await seite.locator("#vw-ub-geld").innerText());
|
||||
pruefe(wo + ": der Monatsumsatz steht da", geld.includes("2.500,00"));
|
||||
pruefe(wo + ": die PayPal-Gebühr wird offen ausgewiesen", geld.includes("90,00"));
|
||||
pruefe(wo + ": das tatsächlich angekommene Netto steht dabei", geld.includes("2.410,00"));
|
||||
|
||||
/* ---- 6. Projekte mit Fortschritt ---- */
|
||||
const proj = await seite.locator("#vw-ub-projekte .vw-ub-projekt").allInnerTexts();
|
||||
pruefe(wo + ": beide laufenden Projekte erscheinen", proj.length === 2, proj.length);
|
||||
pruefe(wo + ": der Fortschritt steht als Zahl da, nicht nur als Balken",
|
||||
proj[0].includes("13 von 20") && proj[0].includes("65"), proj[0].replace(/\n/g, " | "));
|
||||
pruefe(wo + ": man sieht, wer am Zug ist",
|
||||
proj[0].includes("du bist dran") && proj[1].includes("wartet auf Kunden"));
|
||||
const balken = await seite.evaluate(() =>
|
||||
document.querySelector(".vw-ub-balken-voll").style.width);
|
||||
pruefe(wo + ": der Balken zeigt denselben Wert wie der Text", balken === "65%", balken);
|
||||
|
||||
/* ---- 7. Die Kacheln sind echte Knöpfe und führen irgendwohin ---- */
|
||||
const istKnopf = await seite.evaluate(() =>
|
||||
document.querySelector("#vw-ub-beimir .vw-ub-kachel").tagName);
|
||||
pruefe(wo + ": eine Kachel ist ein <button>, kein <div>", istKnopf === "BUTTON", istKnopf);
|
||||
|
||||
await seite.locator('#vw-ub-beimir .vw-ub-kachel[data-ziel="postfach"]').first().click();
|
||||
await seite.waitForTimeout(450);
|
||||
pruefe(wo + ": ein Klick auf 'ungelesene Nachrichten' öffnet das Postfach",
|
||||
(await seite.locator("#vw-titel").innerText()).trim() === "Postfach",
|
||||
await seite.locator("#vw-titel").innerText());
|
||||
|
||||
/* ---- 8. Zurück in die Übersicht, ohne dass etwas hängen bleibt ---- */
|
||||
await seite.click('[data-tab="uebersicht"]');
|
||||
await seite.waitForTimeout(450);
|
||||
pruefe(wo + ": zurück in der Übersicht",
|
||||
(await seite.locator("#vw-titel").innerText()).trim() === "Übersicht");
|
||||
pruefe(wo + ": beim Zurückkommen steht kein leerer Ladehinweis",
|
||||
!(await seite.locator("#vw-ub-laedt").isVisible()));
|
||||
pruefe(wo + ": das Postfach liegt nicht mehr darunter",
|
||||
!(await seite.locator("#vw-postfach-bereich").isVisible()));
|
||||
|
||||
/* ---- 9. Nichts überlappt, nichts läuft aus dem Bild ---- */
|
||||
const ueberlauf = await seite.evaluate(() =>
|
||||
Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth));
|
||||
pruefe(wo + ": nichts ragt seitlich aus dem Fenster", ueberlauf <= 1, ueberlauf + "px");
|
||||
|
||||
/* ---- 10. Tastaturbedienung ---- */
|
||||
const fokus = await seite.evaluate(() => {
|
||||
const k = document.querySelector("#vw-ub-beimir .vw-ub-kachel");
|
||||
k.focus();
|
||||
const st = getComputedStyle(k);
|
||||
return { aktiv: document.activeElement === k, umriss: st.outlineWidth };
|
||||
});
|
||||
pruefe(wo + ": eine Kachel lässt sich mit der Tastatur anspringen", fokus.aktiv);
|
||||
|
||||
pruefe(wo + ": kein Fehler im Protokoll", fehlerImProtokoll.length === 0,
|
||||
fehlerImProtokoll.join(" | "));
|
||||
|
||||
await seite.screenshot({ path: "bild-uebersicht-" + wo.toLowerCase() + ".png", fullPage: true });
|
||||
await seite.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
srv.close();
|
||||
const f = ergebnisse.filter((e) => !e.ok).length;
|
||||
console.log(`\n===== ${ergebnisse.length - f} bestanden, ${f} fehlgeschlagen =====`);
|
||||
process.exitCode = f ? 1 : 0;
|
||||
@@ -0,0 +1,77 @@
|
||||
/* Misst die einzelnen Teile der Kopfleiste auf dem Handy -- damit die
|
||||
Reparatur an der richtigen Stelle ansetzt und nicht geraten wird. */
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-messen-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "pruef.sqlite");
|
||||
process.env.PORT = "4179";
|
||||
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:4179";
|
||||
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 120_000).unref?.();
|
||||
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {});
|
||||
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync("CODE-DOGI-0001", salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
|
||||
/* Bewusst ein langer Name -- kurze Namen wuerden den Fehler verstecken. */
|
||||
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
|
||||
.run("Filipe Fernandes Ribeiro", "admin", hash, salt, 32768, new Date().toISOString());
|
||||
d.close();
|
||||
|
||||
const { chromium } = await import(
|
||||
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await chromium.launch();
|
||||
|
||||
for (const breite of [320, 390, 430]) {
|
||||
const kontext = await browser.newContext({ viewport: { width: breite, height: 844 } });
|
||||
const seite = await kontext.newPage();
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
||||
await seite.click('.rolle[data-rolle="admin"]');
|
||||
await seite.fill("#code", "CODE-DOGI-0001");
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 15000 });
|
||||
await seite.goto(BASIS + "/workspace/uebersicht.html", { waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(300);
|
||||
const r = await seite.evaluate(() => {
|
||||
const k = document.querySelector(".kopfleiste");
|
||||
const teil = (e) => ({
|
||||
was: (e.tagName + "." + String(e.className).split(" ")[0]).slice(0, 34),
|
||||
breite: Math.round(e.getBoundingClientRect().width),
|
||||
rechts: Math.round(e.getBoundingClientRect().right),
|
||||
});
|
||||
return {
|
||||
fenster: window.innerWidth,
|
||||
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
kinder: [...k.children].map(teil),
|
||||
rechtsKinder: [...(k.querySelector(".kopfleiste__rechts")?.children || [])].map(teil),
|
||||
abmeldenSichtbar: (() => {
|
||||
const a = document.querySelector(".abmelden")?.getBoundingClientRect();
|
||||
return a ? a.right <= window.innerWidth : null;
|
||||
})(),
|
||||
};
|
||||
});
|
||||
console.log(`\n--- ${breite}px (Überstand ${r.ueber}px, Abmelden erreichbar: ${r.abmeldenSichtbar})`);
|
||||
for (const t of r.kinder) console.log(` ${t.was.padEnd(34)} b=${String(t.breite).padStart(4)} rechts=${t.rechts}`);
|
||||
console.log(" darin:");
|
||||
for (const t of r.rechtsKinder) console.log(` ${t.was.padEnd(32)} b=${String(t.breite).padStart(4)} rechts=${t.rechts}`);
|
||||
await kontext.close();
|
||||
}
|
||||
await browser.close();
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch {}
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,182 @@
|
||||
/* ===================================================================
|
||||
Browserpruefung des Dashboards.
|
||||
|
||||
Startet denselben Testserver wie pruef-uebersicht.mjs, meldet sich
|
||||
als DogFather bzw. als Creator an und sieht mit einem echten Browser
|
||||
nach: Fehler in der Konsole, ueberstehender Inhalt auf dem Handy,
|
||||
und ob die Karten ueberhaupt gezeichnet werden. Legt Screenshots ab.
|
||||
=================================================================== */
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-uebersicht-b-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "pruef.sqlite");
|
||||
process.env.PORT = "4177";
|
||||
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 echteCookie = express.response.cookie;
|
||||
express.response.cookie = function (name, wert, opt) {
|
||||
return echteCookie.call(this, name, wert, { ...(opt || {}), secure: false });
|
||||
};
|
||||
await import("./index.js");
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
|
||||
const BASIS = "http://127.0.0.1:4177";
|
||||
let fehler = 0;
|
||||
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
|
||||
|
||||
const notbremse = setTimeout(() => {
|
||||
console.log("\nABBRUCH: hing über 150 Sekunden.");
|
||||
process.exit(1);
|
||||
}, 150_000);
|
||||
notbremse.unref?.();
|
||||
|
||||
/* ---------- Daten ------------------------------------------------------ */
|
||||
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "gibt-es-nicht" }),
|
||||
}).catch(() => {});
|
||||
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const N = 32768;
|
||||
const jetzt = new Date().toISOString();
|
||||
function anlegen(name, rolle, code, letzterLogin) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(code, salt, 64, { N, 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, letzter_login) VALUES (?,?,?,?,?,1,?,?)")
|
||||
.run(name, rolle, hash, salt, N, jetzt, letzterLogin ?? null);
|
||||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
}
|
||||
|
||||
const idDogi = anlegen("Test-DogFather", "admin", "CODE-DOGI-0001", jetzt);
|
||||
const namen = ["Anna Sturm", "Ben Kohl", "Cara Weiss", "Deniz Yildiz"];
|
||||
const ids = namen.map((n, i) =>
|
||||
anlegen(n, "creator", `CODE-CRE-000${i + 1}`,
|
||||
i === 3 ? null : new Date(Date.now() - i * 9 * 86400_000).toISOString()));
|
||||
|
||||
const gestern = new Date(Date.now() - 86400_000).toISOString().slice(0, 10);
|
||||
const aufgabe = (c, status, frist, erl) =>
|
||||
d.prepare("INSERT INTO aufgaben (titel, creator_id, status, frist, erledigt_am, erstellt, erstellt_von) VALUES ('Test',?,?,?,?,?,?)")
|
||||
.run(c, status, frist, erl, jetzt, idDogi);
|
||||
|
||||
/* Bewusst unterschiedliche Lagen, damit im Bild sowohl eine ruhige als
|
||||
auch eine brennende Karte zu sehen ist. */
|
||||
for (let k = 0; k < 3; k++) aufgabe(ids[0], "offen", gestern, null);
|
||||
aufgabe(ids[0], "arbeit", null, null);
|
||||
aufgabe(ids[1], "offen", null, null);
|
||||
aufgabe(ids[1], "erledigt", null, jetzt);
|
||||
aufgabe(ids[2], "review", null, null);
|
||||
|
||||
d.prepare("INSERT INTO eintraege (bereich, art, titel, datum, dringlichkeit, status, creator_id, erstellt, erstellt_von) VALUES ('live','notiz','Test',?,'hoch','offen',?,?,?)")
|
||||
.run(gestern, ids[0], jetzt, idDogi);
|
||||
|
||||
d.prepare("INSERT INTO termine (titel, beginn, creator_id, erledigt, erstellt, erstellt_von) VALUES ('Test',?,?,0,?,?)")
|
||||
.run(new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 16), ids[1], jetzt, idDogi);
|
||||
|
||||
for (const [i, feld] of ["a", "b", "c", "d", "e", "f", "g"].entries()) {
|
||||
d.prepare("INSERT INTO startcheck (creator_id, feld, punkt, bewertung, geaendert) VALUES (?,?,?,?,?)")
|
||||
.run(ids[0], feld, "p", i === 0 ? "handlung" : "ok", jetzt);
|
||||
}
|
||||
for (let i = 0; i < 16; i++) {
|
||||
d.prepare("INSERT INTO startcheck (creator_id, feld, punkt, bewertung, geaendert) VALUES (?,?,?,'ok',?)")
|
||||
.run(ids[1], "f" + i, "p", jetzt);
|
||||
}
|
||||
d.prepare("INSERT INTO profile (person_id, naechster_review) VALUES (?,?)").run(ids[0], "2026-09-30");
|
||||
d.prepare("INSERT INTO profile (person_id, naechster_review) VALUES (?,?)").run(ids[1], null);
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(ids[0], idDogi, jetzt);
|
||||
d.close();
|
||||
|
||||
/* ---------- Browser ---------------------------------------------------- */
|
||||
|
||||
const { chromium } = await import(
|
||||
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
|
||||
const browser = await chromium.launch();
|
||||
|
||||
async function ansehen(name, rolle, code, groesse, datei) {
|
||||
const kontext = await browser.newContext({ viewport: groesse });
|
||||
const seite = await kontext.newPage();
|
||||
const konsole = [];
|
||||
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
|
||||
seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message));
|
||||
|
||||
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 });
|
||||
|
||||
await seite.goto(BASIS + "/workspace/uebersicht.html", { waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(500);
|
||||
|
||||
const bild = await seite.evaluate(() => ({
|
||||
karten: document.querySelectorAll(".creator").length,
|
||||
summen: document.querySelectorAll(".summe").length,
|
||||
titel: document.getElementById("titel")?.textContent,
|
||||
unterzeile: document.getElementById("unterzeile")?.textContent,
|
||||
fehler: document.getElementById("fehler")?.textContent,
|
||||
/* Ueberstehender Inhalt: das ist der Fehler, den man am Rechner nie
|
||||
sieht und der auf dem Handy die Seite unbrauchbar macht. */
|
||||
ueberstand: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
breit: [...document.querySelectorAll("main *")]
|
||||
.filter((e) => e.getBoundingClientRect().right > window.innerWidth + 1)
|
||||
.map((e) => e.className).slice(0, 5),
|
||||
/* Der Balken muss eine echte Breite haben, sonst ist der
|
||||
Start-Check nur eine leere Rinne. */
|
||||
balken: [...document.querySelectorAll(".fortschritt__fuellung")]
|
||||
.map((e) => e.style.width),
|
||||
zahlen: [...document.querySelectorAll(".creator")].map((k) => ({
|
||||
name: k.querySelector(".creator__name")?.textContent,
|
||||
achtung: k.dataset.achtung === "ja",
|
||||
werte: [...k.querySelectorAll(".wert__zahl")].map((z) => z.textContent).join("/"),
|
||||
naechstes: k.querySelector(".naechstes__wert")?.textContent,
|
||||
unter: k.querySelector(".creator__unter")?.textContent,
|
||||
})),
|
||||
}));
|
||||
|
||||
await seite.screenshot({ path: datei, fullPage: true });
|
||||
console.log(`\n${name}`);
|
||||
ok(konsole.length === 0, `keine Fehler in der Konsole${konsole.length ? " -> " + konsole.join(" | ") : ""}`);
|
||||
ok(bild.ueberstand <= 0, `nichts steht über (${bild.ueberstand}px, ${bild.breit.join(", ")})`);
|
||||
ok(!bild.fehler, `keine Fehlermeldung auf der Seite ("${bild.fehler}")`);
|
||||
console.log(" ->", JSON.stringify(bild.zahlen, null, 1).replace(/\n\s*/g, " "));
|
||||
console.log(" -> Titel:", bild.titel, "| Summen:", bild.summen, "| Balken:", bild.balken.join(" "));
|
||||
await kontext.close();
|
||||
return bild;
|
||||
}
|
||||
|
||||
const gross = await ansehen("DogFather · Computer", "admin", "CODE-DOGI-0001",
|
||||
{ width: 1440, height: 1000 }, "pruef-uebersicht-computer.png");
|
||||
ok(gross.karten === 4, `vier Karten (${gross.karten})`);
|
||||
ok(gross.summen === 6, `sechs Summen (${gross.summen})`);
|
||||
/* Sortierung: Anna hat 3 ueberfaellige + 1 dringend und muss oben stehen. */
|
||||
ok(gross.zahlen[0]?.name === "Anna Sturm", `die brennendste Karte steht oben (${gross.zahlen[0]?.name})`);
|
||||
ok(gross.zahlen[0]?.achtung === true, "sie ist als Achtung markiert");
|
||||
ok(gross.zahlen.at(-1)?.achtung === false, "eine ruhige Karte ist nicht markiert");
|
||||
ok(gross.zahlen.some((z) => /nicht da|nie angemeldet/.test(z.unter || "")),
|
||||
"lange Abwesenheit wird benannt");
|
||||
|
||||
const klein = await ansehen("DogFather · Handy", "admin", "CODE-DOGI-0001",
|
||||
{ width: 390, height: 844 }, "pruef-uebersicht-handy.png");
|
||||
ok(klein.karten === 4, `vier Karten auch auf dem Handy (${klein.karten})`);
|
||||
|
||||
const eigen = await ansehen("Creator · Handy", "creator", "CODE-CRE-0001",
|
||||
{ width: 390, height: 844 }, "pruef-uebersicht-creator.png");
|
||||
ok(eigen.karten === 1, `Creator sieht genau seine Karte (${eigen.karten})`);
|
||||
ok(eigen.titel === "Mein Stand", `Titel heisst "Mein Stand" (${eigen.titel})`);
|
||||
ok(eigen.summen === 0, "keine Summenzeile bei nur einer Karte");
|
||||
ok(!/betreut von/.test(eigen.zahlen[0]?.unter || ""),
|
||||
"dem Creator wird die Betreuung nicht vorgehalten");
|
||||
|
||||
await browser.close();
|
||||
console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`);
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
process.exit(fehler === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,245 @@
|
||||
/* ===================================================================
|
||||
Pruefung: Dashboard (uebersicht.html + /api/uebersicht/creator)
|
||||
|
||||
Startet einen echten Express-Server mit einer frischen Testdatenbank,
|
||||
legt DogFather, Manager, Scout und zwei Creator an und prueft dann,
|
||||
was jede Rolle sieht. Wichtig ist vor allem der NEGATIVE Fall: Der
|
||||
Scout darf den nicht betreuten Creator nirgends finden, und niemand
|
||||
ausser der Leitung darf den Review-Termin sehen.
|
||||
=================================================================== */
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-uebersicht-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "pruef.sqlite");
|
||||
process.env.PORT = "4176";
|
||||
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 echteCookie = express.response.cookie;
|
||||
express.response.cookie = function (name, wert, opt) {
|
||||
return echteCookie.call(this, name, wert, { ...(opt || {}), secure: false });
|
||||
};
|
||||
|
||||
await import("./index.js");
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
|
||||
const BASIS = "http://127.0.0.1:4176";
|
||||
|
||||
/* Notbremse. index.js faengt uncaughtException ab, damit die Website
|
||||
nicht an einem Einzelfehler stirbt -- im Test heisst das aber: ein
|
||||
Fehler bliebe stumm und der Lauf haenge fuer immer. */
|
||||
const notbremse = setTimeout(() => {
|
||||
console.log("\nABBRUCH: Der Testlauf hing über 90 Sekunden.");
|
||||
process.exit(1);
|
||||
}, 90_000);
|
||||
notbremse.unref?.();
|
||||
|
||||
let fehler = 0;
|
||||
const ok = (bedingung, text) => {
|
||||
console.log((bedingung ? " ok " : " FEHL ") + text);
|
||||
if (!bedingung) fehler++;
|
||||
};
|
||||
|
||||
/* ---------- Testdaten direkt in die Datenbank ------------------------ */
|
||||
|
||||
/* Die Datenbank wird absichtlich erst beim ersten echten Zugriff
|
||||
geoeffnet -- ein Fehler dort soll nie die Website mitreissen. Fuer
|
||||
diesen Test heisst das: erst einen (scheiternden) Anmeldeversuch
|
||||
schicken, damit Schema und Umstellungen laufen. Ohne das faende die
|
||||
eigene Verbindung unten noch gar keine Tabellen. */
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "gibt-es-nicht" }),
|
||||
}).catch(() => {});
|
||||
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const N = 32768;
|
||||
function anlegen(name, rolle, code) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(code, salt, 64, { N, 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, N, new Date().toISOString());
|
||||
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
}
|
||||
|
||||
const idDogi = anlegen("Test-DogFather", "admin", "CODE-DOGI-0001");
|
||||
const idManager = anlegen("Test-Manager", "manager", "CODE-MANA-0001");
|
||||
const idScout = anlegen("Test-Scout", "scout", "CODE-SCOU-0001");
|
||||
const idAnna = anlegen("Anna Betreut", "creator", "CODE-ANNA-0001");
|
||||
const idBen = anlegen("Ben Fremd", "creator", "CODE-BENN-0001");
|
||||
|
||||
/* Anna wird vom Scout betreut, Ben nicht. */
|
||||
d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)")
|
||||
.run(idAnna, idScout, new Date().toISOString());
|
||||
|
||||
const jetzt = new Date().toISOString();
|
||||
const gestern = new Date(Date.now() - 86400_000).toISOString().slice(0, 10);
|
||||
const morgen = new Date(Date.now() + 86400_000).toISOString().slice(0, 16);
|
||||
|
||||
/* Aufgaben: eine ueberfaellige, zwei offene, je eine in Arbeit, im
|
||||
Review und erledigt -- damit jede Zahl der Karte einen anderen Wert
|
||||
bekommt und eine vertauschte Spalte sofort auffiele. */
|
||||
const aufgabe = (creator, status, frist, erledigt) =>
|
||||
d.prepare(
|
||||
"INSERT INTO aufgaben (titel, creator_id, status, frist, erledigt_am, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?)"
|
||||
).run("Test", creator, status, frist, erledigt, jetzt, idDogi);
|
||||
|
||||
aufgabe(idAnna, "offen", gestern, null); // ueberfaellig
|
||||
aufgabe(idAnna, "offen", null, null);
|
||||
aufgabe(idAnna, "arbeit", null, null);
|
||||
aufgabe(idAnna, "review", null, null);
|
||||
aufgabe(idAnna, "erledigt", null, jetzt);
|
||||
aufgabe(idBen, "offen", gestern, null);
|
||||
|
||||
/* Ein dringender, offener Punkt in den Bereichen -- und einer, der
|
||||
erledigt ist. Der erledigte darf NICHT mitzaehlen. */
|
||||
const eintrag = (creator, dringlichkeit, status) =>
|
||||
d.prepare(
|
||||
"INSERT INTO eintraege (bereich, art, titel, datum, dringlichkeit, status, creator_id, erstellt, erstellt_von) VALUES ('live','notiz','Test',?,?,?,?,?,?)"
|
||||
).run(gestern, dringlichkeit, status, creator, jetzt, idDogi);
|
||||
eintrag(idAnna, "hoch", "offen");
|
||||
eintrag(idAnna, "hoch", "erledigt");
|
||||
eintrag(idAnna, "mittel", "offen");
|
||||
|
||||
/* Termin morgen -- der naechste anstehende. Ein bereits erledigter und
|
||||
ein vergangener duerfen ihn nicht verdraengen. */
|
||||
const termin = (beginn, erledigt) =>
|
||||
d.prepare(
|
||||
"INSERT INTO termine (titel, beginn, creator_id, erledigt, erstellt, erstellt_von) VALUES ('Test',?,?,?,?,?)"
|
||||
).run(beginn, idAnna, erledigt, jetzt, idDogi);
|
||||
termin(morgen, 0);
|
||||
termin(new Date(Date.now() + 3 * 86400_000).toISOString().slice(0, 16), 0);
|
||||
termin(new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 16), 1); // erledigt
|
||||
termin("2020-01-01T10:00", 0); // vergangen
|
||||
|
||||
/* Start-Check: drei geprueft, davon einer mit Handlungsbedarf. */
|
||||
const check = (feld, punkt, bewertung) =>
|
||||
d.prepare(
|
||||
"INSERT INTO startcheck (creator_id, feld, punkt, bewertung, geaendert) VALUES (?,?,?,?,?)"
|
||||
).run(idAnna, feld, punkt, bewertung, jetzt);
|
||||
check("technik", "licht", "ok");
|
||||
check("technik", "ton", "mittel");
|
||||
check("inhalt", "plan", "handlung");
|
||||
check("inhalt", "offen", null); // angelegt, aber nicht bewertet -> zaehlt nicht
|
||||
|
||||
/* Review-Termin im Profil -- nur die Leitung darf ihn sehen. */
|
||||
d.prepare("INSERT INTO profile (person_id, naechster_review) VALUES (?,?)")
|
||||
.run(idAnna, "2026-12-24");
|
||||
|
||||
d.close();
|
||||
|
||||
/* ---------- Hilfen --------------------------------------------------- */
|
||||
|
||||
async function anmelden(rolle, code) {
|
||||
const a = await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle, code }),
|
||||
redirect: "manual",
|
||||
});
|
||||
const keks = (a.headers.getSetCookie?.() || []).map((c) => c.split(";")[0]).join("; ");
|
||||
if (!a.ok || !keks) throw new Error(`Anmeldung ${rolle} gescheitert (${a.status})`);
|
||||
return keks;
|
||||
}
|
||||
const hole = (pfad, keks) =>
|
||||
fetch(BASIS + pfad, { headers: { cookie: keks }, redirect: "manual" });
|
||||
|
||||
/* ---------- Pruefungen ------------------------------------------------ */
|
||||
|
||||
console.log("\n1) Ohne Anmeldung");
|
||||
{
|
||||
const a = await fetch(BASIS + "/workspace/uebersicht.html", { redirect: "manual" });
|
||||
ok(a.status === 302, `Seite leitet um statt sie auszuliefern (${a.status})`);
|
||||
const b = await fetch(BASIS + "/workspace/api/uebersicht/creator", { redirect: "manual" });
|
||||
ok(b.status === 401, `Schnittstelle antwortet 401 (${b.status})`);
|
||||
}
|
||||
|
||||
console.log("\n2) DogFather sieht alle");
|
||||
const kDogi = await anmelden("admin", "CODE-DOGI-0001");
|
||||
{
|
||||
const a = await hole("/workspace/uebersicht.html", kDogi);
|
||||
ok(a.status === 200, `Seite wird ausgeliefert (${a.status})`);
|
||||
const d2 = await (await hole("/workspace/api/uebersicht/creator", kDogi)).json();
|
||||
ok(d2.creator.length === 2, `beide Creator (${d2.creator.length})`);
|
||||
ok(d2.eigen === false, "eigen = false");
|
||||
ok(d2.check_gesamt === 16, "Start-Check-Gesamtzahl mitgeliefert");
|
||||
const anna = d2.creator.find((c) => c.name === "Anna Betreut");
|
||||
ok(anna.ueberfaellig === 1, `Anna: 1 ueberfaellig (${anna.ueberfaellig})`);
|
||||
ok(anna.offen === 2, `Anna: 2 offen (${anna.offen})`);
|
||||
ok(anna.arbeit === 1, `Anna: 1 in Arbeit (${anna.arbeit})`);
|
||||
ok(anna.review === 1, `Anna: 1 im Review (${anna.review})`);
|
||||
ok(anna.erledigt30 === 1, `Anna: 1 erledigt in 30 Tagen (${anna.erledigt30})`);
|
||||
ok(anna.dringend === 1, `Anna: 1 dringend, Erledigtes zaehlt nicht mit (${anna.dringend})`);
|
||||
ok(anna.check_geprueft === 3, `Anna: 3 Punkte bewertet (${anna.check_geprueft})`);
|
||||
ok(anna.check_handlung === 1, `Anna: 1 Punkt mit Handlungsbedarf (${anna.check_handlung})`);
|
||||
ok(String(anna.naechster_termin).startsWith(
|
||||
new Date(Date.now() + 86400_000).toISOString().slice(0, 10)),
|
||||
`Anna: naechster Termin ist morgen, nicht der erledigte (${anna.naechster_termin})`);
|
||||
ok(anna.betreuer === "Test-Scout", `Anna: Betreuer steht dran (${anna.betreuer})`);
|
||||
ok(anna.naechster_review === "2026-12-24", "Leitung sieht den Review-Termin");
|
||||
ok(anna.hat_profil === 1, "Profil erkannt");
|
||||
const ben = d2.creator.find((c) => c.name === "Ben Fremd");
|
||||
ok(ben.betreuer === null, "Ben hat keinen Betreuer");
|
||||
ok(ben.hat_profil === 0, "Ben hat kein Profil");
|
||||
}
|
||||
|
||||
console.log("\n3) Manager sieht dasselbe");
|
||||
{
|
||||
const k = await anmelden("manager", "CODE-MANA-0001");
|
||||
const d2 = await (await hole("/workspace/api/uebersicht/creator", k)).json();
|
||||
ok(d2.creator.length === 2, `beide Creator (${d2.creator.length})`);
|
||||
ok(d2.creator[0].naechster_review !== undefined
|
||||
|| d2.creator[1].naechster_review !== undefined, "Manager sieht Review-Termine");
|
||||
}
|
||||
|
||||
console.log("\n4) Scout sieht nur die betreute Person");
|
||||
{
|
||||
const k = await anmelden("scout", "CODE-SCOU-0001");
|
||||
const a = await hole("/workspace/uebersicht.html", k);
|
||||
ok(a.status === 200, `Seite wird ausgeliefert (${a.status})`);
|
||||
const d2 = await (await hole("/workspace/api/uebersicht/creator", k)).json();
|
||||
ok(d2.creator.length === 1, `genau ein Creator (${d2.creator.length})`);
|
||||
ok(d2.creator[0].name === "Anna Betreut", "und zwar die betreute");
|
||||
ok(!d2.creator.some((c) => c.name === "Ben Fremd"), "Ben taucht NICHT auf");
|
||||
ok(!("naechster_review" in d2.creator[0]), "Scout sieht KEINEN Review-Termin");
|
||||
}
|
||||
|
||||
console.log("\n5) Creator sieht nur sich selbst");
|
||||
{
|
||||
const k = await anmelden("creator", "CODE-ANNA-0001");
|
||||
const a = await hole("/workspace/uebersicht.html", k);
|
||||
ok(a.status === 200, `Seite wird ausgeliefert (${a.status})`);
|
||||
const d2 = await (await hole("/workspace/api/uebersicht/creator", k)).json();
|
||||
ok(d2.eigen === true, "eigen = true");
|
||||
ok(d2.creator.length === 1, `genau ein Eintrag (${d2.creator.length})`);
|
||||
ok(d2.creator[0].name === "Anna Betreut", "und zwar er selbst");
|
||||
ok(!("naechster_review" in d2.creator[0]), "Creator sieht KEINEN Review-Termin");
|
||||
}
|
||||
|
||||
console.log("\n6) Scout ohne Zuteilung bekommt eine leere, keine fremde Liste");
|
||||
{
|
||||
const d3 = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
d3.prepare("DELETE FROM betreuung WHERE creator_id = ?").run(idAnna);
|
||||
d3.close();
|
||||
const k = await anmelden("scout", "CODE-SCOU-0001");
|
||||
const d2 = await (await hole("/workspace/api/uebersicht/creator", k)).json();
|
||||
ok(d2.creator.length === 0, `leer statt alle (${d2.creator.length})`);
|
||||
}
|
||||
|
||||
console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Prüfung(en) fehlgeschlagen."}`);
|
||||
/* Windows gibt die Datei erst frei, wenn der Server sie loslaesst -- das
|
||||
Aufraeumen darf den Lauf deshalb nicht zum Scheitern bringen. Der
|
||||
Ordner liegt im Temp-Verzeichnis und verschwindet ohnehin. */
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||||
process.exit(fehler === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,103 @@
|
||||
/* Kurzpruefung: Steht die Kopfleiste auf dem Handy ueber -- und zwar
|
||||
auf ALLEN Seiten oder nur auf der neuen? Beantwortet die Frage, ob
|
||||
ich einen eigenen Fehler gebaut oder einen alten gefunden habe. */
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ordner = mkdtempSync(join(tmpdir(), "ws-kopf-"));
|
||||
process.env.WORKSPACE_DB = join(ordner, "pruef.sqlite");
|
||||
process.env.PORT = "4178";
|
||||
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 echteCookie = express.response.cookie;
|
||||
express.response.cookie = function (n, w, o) {
|
||||
return echteCookie.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:4178";
|
||||
setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 150_000).unref?.();
|
||||
|
||||
await fetch(BASIS + "/workspace/api/anmelden", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ rolle: "admin", code: "x" }),
|
||||
}).catch(() => {});
|
||||
|
||||
const { DatabaseSync } = await import("node:sqlite");
|
||||
const { scryptSync, randomBytes } = await import("node:crypto");
|
||||
const d = new DatabaseSync(process.env.WORKSPACE_DB);
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync("CODE-DOGI-0001", 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("Test-DogFather", "admin", hash, salt, 32768, new Date().toISOString());
|
||||
d.close();
|
||||
|
||||
const { chromium } = await import(
|
||||
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||||
const browser = await chromium.launch();
|
||||
|
||||
/* Alle Seiten, die eine angemeldete Person zu sehen bekommt. Geprueft
|
||||
wird auf beiden Groessen, weil die geteilten Klassen (.kopf-zeile,
|
||||
.inhalt--breit, .leise) am 31.08.2026 von aufgaben.css nach start.css
|
||||
gewandert sind -- faellt dabei etwas herunter, sieht man es hier. */
|
||||
const SEITEN = ["start.html", "uebersicht.html", "aufgaben.html", "kalender.html",
|
||||
"personen.html", "profil.html", "wissen.html", "dateien.html",
|
||||
"calls.html", "startcheck.html", "report.html", "scouting.html",
|
||||
"automation.html", "bereich.html?b=live"];
|
||||
|
||||
let fehler = 0;
|
||||
for (const [breite, hoehe, wie] of [[1440, 1000, "Computer"], [390, 844, "Handy"]]) {
|
||||
console.log(`\n=== ${wie} (${breite}px)`);
|
||||
const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
|
||||
const seite = await kontext.newPage();
|
||||
const konsole = [];
|
||||
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
|
||||
seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message));
|
||||
|
||||
await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" });
|
||||
await seite.click('.rolle[data-rolle="admin"]');
|
||||
await seite.fill("#code", "CODE-DOGI-0001");
|
||||
await seite.click("button[type=submit]");
|
||||
await seite.waitForURL("**/start.html", { timeout: 15000 });
|
||||
|
||||
for (const s of SEITEN) {
|
||||
konsole.length = 0;
|
||||
await seite.goto(BASIS + "/workspace/" + s, { waitUntil: "networkidle" });
|
||||
await seite.waitForTimeout(300);
|
||||
const r = await seite.evaluate(() => ({
|
||||
ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
||||
schuld: [...document.querySelectorAll("body *")]
|
||||
.filter((e) => e.getBoundingClientRect().right > window.innerWidth + 1)
|
||||
.map((e) => (e.tagName + "." + e.className).slice(0, 40)).slice(0, 4),
|
||||
/* Die verschobenen Klassen muessen weiterhin greifen. .inhalt--breit
|
||||
setzt 1240px -- kommt nur 880px an, ist die Regel verloren. */
|
||||
breit: document.querySelector(".inhalt--breit")
|
||||
? getComputedStyle(document.querySelector(".inhalt--breit")).maxWidth : null,
|
||||
kopfzeile: document.querySelector(".kopf-zeile")
|
||||
? getComputedStyle(document.querySelector(".kopf-zeile")).display : null,
|
||||
abmelden: (() => {
|
||||
const a = document.querySelector(".abmelden")?.getBoundingClientRect();
|
||||
return a ? a.right <= window.innerWidth && a.left >= 0 : null;
|
||||
})(),
|
||||
}));
|
||||
const gut = r.ueber <= 0 && konsole.length === 0 && r.abmelden !== false
|
||||
&& (r.breit === null || r.breit === "1240px")
|
||||
&& (r.kopfzeile === null || r.kopfzeile === "flex");
|
||||
if (!gut) fehler++;
|
||||
console.log(` ${gut ? "ok " : "FEHL"} ${String(s).padEnd(20)}`
|
||||
+ `über=${String(r.ueber).padStart(3)}px abmelden=${r.abmelden}`
|
||||
+ ` breit=${r.breit} kopfzeile=${r.kopfzeile}`
|
||||
+ (konsole.length ? " KONSOLE: " + konsole.join(" | ") : "")
|
||||
+ (r.schuld.length ? " -> " + r.schuld.join(" | ") : ""));
|
||||
}
|
||||
await kontext.close();
|
||||
}
|
||||
await browser.close();
|
||||
console.log(`\n${fehler === 0 ? "Alles in Ordnung." : fehler + " Seite(n) fehlerhaft."}`);
|
||||
try { rmSync(ordner, { recursive: true, force: true }); } catch {}
|
||||
process.exit(fehler === 0 ? 0 : 1);
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import express from "express";
|
||||
import {
|
||||
db, protokolliere, echteIp, sitzungLesen, betreutWo, istLeitung, ROLLEN_SORTIERUNG,
|
||||
db, protokolliere, echteIp, sitzungLesen, betreutWo, istLeitung, ROLLEN_SORTIERUNG, betreuteIds,
|
||||
} from "./workspace.js";
|
||||
|
||||
export const aufgabenRouter = express.Router();
|
||||
@@ -188,6 +188,84 @@ function pruefeFelder(körper, { neu }) {
|
||||
return { aus, fehler };
|
||||
}
|
||||
|
||||
/* ---------- Gesamtuebersicht ----------------------------------------------
|
||||
"Die Admin-Rolle behaelt den Gesamtueberblick" (Konzept, Seite 2) und
|
||||
"UEBERSICHT -- alles zentral" (Seite 1). Genau das fehlte: eine
|
||||
Ansicht, die JE CREATOR zeigt, wie es steht, statt nur die eigenen
|
||||
Zahlen.
|
||||
|
||||
Fuehrt keine eigenen Daten -- alles kommt aus Aufgaben, Terminen,
|
||||
Bereichen, Profil und Start-Check. Wer welchen Creator sieht, richtet
|
||||
sich nach derselben Betreuungsregel wie ueberall. */
|
||||
|
||||
aufgabenRouter.get("/workspace/api/uebersicht/creator", (req, res) => {
|
||||
try {
|
||||
const person = req.person;
|
||||
let wo = "p.rolle = 'creator' AND p.aktiv = 1";
|
||||
let werte = [];
|
||||
if (person.rolle === "creator") {
|
||||
wo += " AND p.id = ?";
|
||||
werte = [person.id];
|
||||
} else if (person.rolle === "scout") {
|
||||
const ids = betreuteIds(person);
|
||||
if (!ids.length) return res.json({ creator: [], eigen: false });
|
||||
wo += ` AND p.id IN (${ids.map(() => "?").join(",")})`;
|
||||
werte = ids;
|
||||
}
|
||||
|
||||
const heute = new Date().toISOString().slice(0, 10);
|
||||
const jetztIso = new Date().toISOString();
|
||||
|
||||
const liste = db().prepare(`
|
||||
SELECT p.id, p.name, p.letzter_login,
|
||||
(SELECT name FROM personen b
|
||||
WHERE b.id = (SELECT betreuer_id FROM betreuung WHERE creator_id = p.id)) AS betreuer,
|
||||
(SELECT COUNT(*) FROM aufgaben a
|
||||
WHERE a.creator_id = p.id AND a.status = 'offen') AS offen,
|
||||
(SELECT COUNT(*) FROM aufgaben a
|
||||
WHERE a.creator_id = p.id AND a.status = 'arbeit') AS arbeit,
|
||||
(SELECT COUNT(*) FROM aufgaben a
|
||||
WHERE a.creator_id = p.id AND a.status = 'review') AS review,
|
||||
(SELECT COUNT(*) FROM aufgaben a
|
||||
WHERE a.creator_id = p.id AND a.status <> 'erledigt'
|
||||
AND a.frist IS NOT NULL AND a.frist < ?) AS ueberfaellig,
|
||||
(SELECT COUNT(*) FROM aufgaben a
|
||||
WHERE a.creator_id = p.id AND a.erledigt_am IS NOT NULL
|
||||
AND a.erledigt_am >= ?) AS erledigt30,
|
||||
(SELECT COUNT(*) FROM eintraege e
|
||||
WHERE e.creator_id = p.id AND e.status = 'offen'
|
||||
AND e.dringlichkeit = 'hoch') AS dringend,
|
||||
(SELECT MIN(t.beginn) FROM termine t
|
||||
WHERE (t.creator_id = p.id OR t.teilnehmer_id = p.id)
|
||||
AND t.beginn >= ? AND t.erledigt = 0) AS naechster_termin,
|
||||
(SELECT COUNT(*) FROM startcheck s
|
||||
WHERE s.creator_id = p.id AND s.bewertung IS NOT NULL) AS check_geprueft,
|
||||
(SELECT COUNT(*) FROM startcheck s
|
||||
WHERE s.creator_id = p.id AND s.bewertung = 'handlung') AS check_handlung,
|
||||
(SELECT naechster_review FROM profile f WHERE f.person_id = p.id) AS naechster_review,
|
||||
(SELECT COUNT(*) FROM profile f WHERE f.person_id = p.id) AS hat_profil
|
||||
FROM personen p
|
||||
WHERE ${wo}
|
||||
ORDER BY p.name`).all(
|
||||
heute, new Date(Date.now() - 30 * 86400_000).toISOString(),
|
||||
heute + "T00:00", ...werte);
|
||||
|
||||
/* Der Review-Termin ist Steuerungswissen -- ein Creator sieht ihn in
|
||||
seinem Profil auch nicht, also hier ebenso wenig. */
|
||||
if (!istLeitung(person)) for (const c of liste) delete c.naechster_review;
|
||||
|
||||
res.json({
|
||||
creator: liste,
|
||||
eigen: person.rolle === "creator",
|
||||
check_gesamt: 16,
|
||||
stand: jetztIso,
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[workspace] Gesamtuebersicht:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Rueckmeldungen ("Aufgaben & Feedback") ------------------------
|
||||
Wer die Aufgabe sieht, darf mitreden. Geprueft wird das ueber dieselbe
|
||||
Sichtbarkeitsregel wie fuer die Aufgabe selbst -- eine zweite Regel
|
||||
|
||||
@@ -636,6 +636,7 @@ export const workspaceRouter = express.Router();
|
||||
das Skript ihn wegschickt. Sichtbar sein soll sie gar nicht. */
|
||||
const GESCHUETZT = {
|
||||
"/workspace/start.html": null,
|
||||
"/workspace/uebersicht.html": null,
|
||||
"/workspace/aufgaben.html": null,
|
||||
"/workspace/personen.html": ["admin", "manager"],
|
||||
"/workspace/profil.html": ["admin", "manager", "scout", "creator"],
|
||||
|
||||
@@ -2,19 +2,9 @@
|
||||
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); }
|
||||
|
||||
|
||||
.leise { color: var(--text-still); font-weight: 400; }
|
||||
/* .inhalt--breit, .zurueck, .kopf-zeile und .leise sind am 31.08.2026 nach
|
||||
start.css gewandert -- sie werden auf jeder Seite gebraucht, aber nicht
|
||||
jede Seite laedt diese Datei hier. */
|
||||
|
||||
/* ---------- Formular -------------------------------------------------- */
|
||||
|
||||
|
||||
@@ -36,7 +36,22 @@ body.start {
|
||||
border-bottom: 1px solid var(--rand);
|
||||
}
|
||||
|
||||
.kopfleiste .marke { margin: 0; font-size: .72rem; }
|
||||
/* Damit in einer Flex-Zeile ueberhaupt etwas schrumpfen KANN, braucht es
|
||||
min-width: 0. Ohne diese eine Zeile behielt jedes Teil seine volle
|
||||
Wunschbreite -- Zurueck (118) + Marke (141) + rechte Gruppe (297)
|
||||
ergaben 556 px auf einem 390 px breiten Handy. Die Kopfleiste schob
|
||||
214 px aus dem Bild und der Abmelden-Knopf war dort nicht mehr zu
|
||||
erreichen. Auf JEDER Seite, seit es die Kopfleiste gibt; aufgefallen
|
||||
erst beim Messen, weil man am Rechner nie danebengreift. */
|
||||
.kopfleiste > * { min-width: 0; }
|
||||
|
||||
.kopfleiste .marke {
|
||||
margin: 0; font-size: .72rem;
|
||||
/* Die Marke gibt als Erstes nach: Ihr Inhalt steht direkt darunter
|
||||
noch einmal als Ueberschrift, der Abmelden-Knopf dagegen nirgends. */
|
||||
flex: 1 1 auto;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Zurück-Knopf. Steht ganz links, weil dort jeder ihn erwartet -- gleiche
|
||||
Stelle wie im Browser. Auf der Startseite gibt es ihn gar nicht: Dort
|
||||
@@ -106,11 +121,14 @@ body.start {
|
||||
.zurueck-knopf:hover .zurueck-knopf__pfeil { transform: none; }
|
||||
}
|
||||
|
||||
.kopfleiste__rechts { display: flex; align-items: center; gap: 12px; }
|
||||
.kopfleiste__rechts { display: flex; align-items: center; gap: 12px; flex: 0 1 auto; min-width: 0; }
|
||||
.kopfleiste__rechts > * { flex: 0 0 auto; }
|
||||
|
||||
.wer {
|
||||
font-size: .86rem; color: var(--text-leise);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 46vw;
|
||||
/* Der Name darf kuerzer werden -- der Knopf daneben nicht. */
|
||||
flex: 0 1 auto; min-width: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +140,25 @@ body.start {
|
||||
padding: clamp(28px, 5vw, 56px) clamp(16px, 4vw, 40px) 64px;
|
||||
}
|
||||
|
||||
/* Diese vier standen bis zum 31.08.2026 in aufgaben.css -- also in einer
|
||||
Datei, die eine neue Seite gar nicht laden muss. Genau dadurch war das
|
||||
Dashboard beim ersten Aufbau schmal und ohne Kopfzeilen-Layout: die
|
||||
Klassen im HTML gab es, die Regeln dazu nicht. Derselbe Fehler ist
|
||||
vorher schon zweimal passiert (.knopf-still, .schalter). Was auf
|
||||
mehreren Seiten benutzt wird, gehoert hierher, wo jede Seite es hat. */
|
||||
.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); }
|
||||
|
||||
.leise { color: var(--text-still); font-weight: 400; }
|
||||
|
||||
.willkommen { margin-bottom: 38px; }
|
||||
.willkommen .titel { margin-bottom: 6px; }
|
||||
|
||||
@@ -199,9 +236,30 @@ body.start {
|
||||
.inhalt .fuss { margin-top: 40px; text-align: left; }
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.kopfleiste { padding: 12px 16px; }
|
||||
.kopfleiste .marke { font-size: .64rem; }
|
||||
.wer { max-width: 34vw; }
|
||||
.kopfleiste { padding: 12px 16px; gap: 10px; }
|
||||
/* Die Marke wich zwar zurueck, nahm aber immer noch so viel Platz,
|
||||
dass vom Namen nur noch drei Pixel uebrig blieben. Auf dem Handy
|
||||
verschwindet sie deshalb -- genau derselbe Text steht zwei Zeilen
|
||||
tiefer als Ueberschrift, der Name des Angemeldeten dagegen
|
||||
nirgends sonst.
|
||||
|
||||
Aber nur dort, wo der Zurueck-Knopf sichtbar ist: Auf der
|
||||
Startseite gibt es ihn nicht, dort waere die linke Haelfte sonst
|
||||
leer und die Leiste saehe kaputt aus. */
|
||||
.kopfleiste:has(.zurueck-knopf:not([hidden])) .marke { display: none; }
|
||||
.kopfleiste__rechts { margin-left: auto; }
|
||||
.wer { max-width: none; }
|
||||
/* Der Pfeil allein sagt "zurueck" -- das Wort daneben kostet auf dem
|
||||
Handy 65 px, die dem Abmelden-Knopf fehlen. Vorlesende Programme
|
||||
verlieren nichts: Der Text bleibt im Dokument und wird nur
|
||||
unsichtbar gerechnet. */
|
||||
.zurueck-knopf { padding: 9px 12px 9px 11px; }
|
||||
.zurueck-knopf__text {
|
||||
position: absolute; width: 1px; height: 1px;
|
||||
margin: -1px; padding: 0; overflow: hidden;
|
||||
clip-path: inset(50%); white-space: nowrap;
|
||||
}
|
||||
.zurueck-knopf { gap: 0; }
|
||||
}
|
||||
|
||||
/* Zurueckhaltender Zweitknopf. Gehoert bewusst hierher und nicht in eine
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/* ===================================================================
|
||||
Dashboard -- die Gesamtuebersicht je Creator.
|
||||
|
||||
Grundgedanke: eine Karte je Person, in der Reihenfolge des Drucks.
|
||||
Ruhige Karten fuer alles, was laeuft; nur was Handlung braucht,
|
||||
bekommt Farbe. Waere jede Zahl bunt, waere keine mehr auffaellig --
|
||||
und die Augen haetten nichts, worauf sie sich ausruhen koennen.
|
||||
=================================================================== */
|
||||
|
||||
/* ---------- Summen oben ---------------------------------------------------- */
|
||||
|
||||
.summen {
|
||||
display: grid; gap: 8px; margin-bottom: 26px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
.summe {
|
||||
display: grid; gap: 2px; padding: 14px 16px;
|
||||
border: 1px solid var(--rand); border-radius: var(--radius-klein);
|
||||
background: rgba(255, 255, 255, .028);
|
||||
}
|
||||
.summe[data-warn="ja"] {
|
||||
border-color: rgba(255, 154, 162, .32);
|
||||
background: rgba(255, 154, 162, .05);
|
||||
}
|
||||
.summe__zahl {
|
||||
font-size: 1.6rem; font-weight: 700; line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums; color: var(--text);
|
||||
}
|
||||
.summe[data-warn="ja"] .summe__zahl { color: var(--warn); }
|
||||
.summe__name {
|
||||
font-size: .72rem; font-weight: 600; letter-spacing: .06em;
|
||||
text-transform: uppercase; color: var(--text-still);
|
||||
}
|
||||
|
||||
/* ---------- Karten --------------------------------------------------------- */
|
||||
|
||||
.creator-liste {
|
||||
display: grid; gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(430px, 1fr));
|
||||
}
|
||||
.creator {
|
||||
position: relative; overflow: hidden;
|
||||
padding: 18px 20px 16px;
|
||||
border: 1px solid var(--rand); border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, .026);
|
||||
}
|
||||
/* Ein schmaler Streifen an der Kante statt einer eingefaerbten Karte:
|
||||
sichtbar genug zum Ueberfliegen, ruhig genug zum Lesen. */
|
||||
.creator::before {
|
||||
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
|
||||
background: linear-gradient(180deg, var(--akzent), transparent 85%);
|
||||
opacity: .35;
|
||||
}
|
||||
.creator[data-achtung="ja"] {
|
||||
border-color: rgba(255, 154, 162, .26);
|
||||
}
|
||||
.creator[data-achtung="ja"]::before {
|
||||
background: linear-gradient(180deg, var(--warn), transparent 85%);
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
.creator__kopf {
|
||||
display: flex; gap: 18px; align-items: flex-start;
|
||||
justify-content: space-between; flex-wrap: wrap;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.creator__wer { min-width: 0; }
|
||||
.creator__name {
|
||||
margin: 0 0 3px; font-size: 1.14rem; font-weight: 700;
|
||||
letter-spacing: -.01em; color: var(--text);
|
||||
}
|
||||
.creator__unter { margin: 0; font-size: .8rem; color: var(--text-still); }
|
||||
.creator__unter[data-kalt="ja"] { color: var(--warn); opacity: .82; }
|
||||
|
||||
.naechstes { display: grid; gap: 2px; justify-items: end; text-align: right; flex: none; }
|
||||
.naechstes__schild {
|
||||
font-size: .66rem; font-weight: 700; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--text-still);
|
||||
}
|
||||
.naechstes__wert {
|
||||
font-size: .92rem; font-weight: 650; color: var(--text);
|
||||
text-decoration: none; font-variant-numeric: tabular-nums;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
a.naechstes__wert:hover { color: var(--akzent); border-bottom-color: rgba(63, 189, 245, .45); }
|
||||
.naechstes__wert--leer { color: var(--text-still); font-weight: 500; }
|
||||
|
||||
/* ---------- Zahlenreihe ---------------------------------------------------- */
|
||||
|
||||
.werte {
|
||||
display: grid; gap: 6px;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
.wert {
|
||||
display: grid; gap: 1px; padding: 9px 4px 8px;
|
||||
text-align: center; text-decoration: none;
|
||||
border: 1px solid transparent; border-radius: var(--radius-klein);
|
||||
background: rgba(255, 255, 255, .03);
|
||||
transition: border-color var(--tempo), background var(--tempo);
|
||||
}
|
||||
a.wert:hover { border-color: var(--rand-hell); background: rgba(255, 255, 255, .06); }
|
||||
.wert__zahl {
|
||||
font-size: 1.22rem; font-weight: 700; line-height: 1.15;
|
||||
font-variant-numeric: tabular-nums; color: var(--text);
|
||||
}
|
||||
/* Eine Zeile, ohne Umbruch: "ERLEDIGT 30 T." brach sonst als einziges
|
||||
Feld um und zog die ganze Reihe in die Hoehe. Lieber eine Spur
|
||||
kleiner als sechs Kaesten, von denen einer aus der Reihe faellt. */
|
||||
.wert__name {
|
||||
font-size: .6rem; font-weight: 600; letter-spacing: .03em;
|
||||
text-transform: uppercase; color: var(--text-still);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
/* Eine Null ist eine gute Nachricht -- sie darf zuruecktreten. */
|
||||
.wert[data-leer="ja"] { background: rgba(255, 255, 255, .016); }
|
||||
.wert[data-leer="ja"] .wert__zahl { color: var(--text-still); font-weight: 600; }
|
||||
.wert[data-warn="ja"] {
|
||||
border-color: rgba(255, 154, 162, .3); background: rgba(255, 154, 162, .07);
|
||||
}
|
||||
.wert[data-warn="ja"] .wert__zahl { color: var(--warn); }
|
||||
.wert[data-warn="ja"] .wert__name { color: rgba(255, 154, 162, .8); }
|
||||
|
||||
/* ---------- Start-Check ---------------------------------------------------- */
|
||||
|
||||
.fortschritt {
|
||||
display: block; margin-top: 13px; padding: 11px 13px 12px;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--rand); border-radius: var(--radius-klein);
|
||||
background: rgba(255, 255, 255, .02);
|
||||
transition: border-color var(--tempo);
|
||||
}
|
||||
.fortschritt:hover { border-color: var(--rand-hell); }
|
||||
.fortschritt__zeile {
|
||||
display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.fortschritt__schild {
|
||||
font-size: .66rem; font-weight: 700; letter-spacing: .08em;
|
||||
text-transform: uppercase; color: var(--text-still);
|
||||
}
|
||||
.fortschritt__text { font-size: .8rem; color: var(--text-leise); font-variant-numeric: tabular-nums; }
|
||||
.fortschritt__warn {
|
||||
margin-left: auto; padding: 1px 8px;
|
||||
font-size: .7rem; font-weight: 650; color: var(--warn);
|
||||
background: rgba(255, 154, 162, .1);
|
||||
border: 1px solid rgba(255, 154, 162, .26); border-radius: 999px;
|
||||
}
|
||||
.fortschritt__balken {
|
||||
display: block; height: 5px; border-radius: 999px; overflow: hidden;
|
||||
background: rgba(255, 255, 255, .07);
|
||||
}
|
||||
.fortschritt__fuellung {
|
||||
display: block; height: 100%; border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--akzent-tief), var(--akzent));
|
||||
transition: width .4s cubic-bezier(.4, 0, .2, 1);
|
||||
}
|
||||
.fortschritt__fuellung[data-warn="ja"] {
|
||||
background: linear-gradient(90deg, rgba(255, 154, 162, .55), var(--warn));
|
||||
}
|
||||
.fortschritt[data-fertig="ja"] .fortschritt__text { color: var(--akzent); }
|
||||
|
||||
.creator__luecke {
|
||||
display: block; margin: 10px 0 0; padding: 8px 12px;
|
||||
font-size: .78rem; color: var(--text-leise); text-decoration: none;
|
||||
border: 1px dashed var(--rand); border-radius: var(--radius-klein);
|
||||
}
|
||||
.creator__luecke:hover { color: var(--akzent); border-color: rgba(63, 189, 245, .35); }
|
||||
|
||||
/* ---------- Rest ----------------------------------------------------------- */
|
||||
|
||||
.leer-hinweis {
|
||||
padding: 30px; text-align: center; color: var(--text-still); font-size: .9rem;
|
||||
border: 1px dashed var(--rand); border-radius: var(--radius);
|
||||
}
|
||||
.stand {
|
||||
margin: 20px 0 0; font-size: .74rem; color: var(--text-still); text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.creator-liste { grid-template-columns: 1fr; }
|
||||
.creator { padding: 16px 15px 14px; }
|
||||
/* Sechs Spalten waeren auf dem Handy je 45 px breit -- die Beschriftung
|
||||
bricht dann mitten im Wort. Zwei Reihen zu dritt lesen sich sauber. */
|
||||
.werte { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.creator__kopf { gap: 10px; }
|
||||
.naechstes { justify-items: flex-start; text-align: left; }
|
||||
.summen { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.stand { text-align: left; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wert, .fortschritt, .fortschritt__fuellung { transition: none; }
|
||||
}
|
||||
@@ -17,10 +17,14 @@
|
||||
`rollen` steuert, wem ein Bereich überhaupt angezeigt wird - schon
|
||||
jetzt, damit die Trennung von Anfang an sichtbar ist.
|
||||
|
||||
"Dashboard" stand hier als Kachel, die man nicht öffnen kann -- man
|
||||
ist ja schon darauf. Ein Eintrag, der nirgendwohin führt, sieht aus
|
||||
wie etwas Unfertiges. Raus. */
|
||||
"Dashboard" führte lange nirgendwohin -- man stand ja schon auf
|
||||
dieser Seite. Statt die Kachel zu streichen, hat sie jetzt ein
|
||||
Ziel: die Gesamtübersicht je Creator, die das Konzept auf Seite 1
|
||||
als "ÜBERSICHT -- alles zentral" verspricht und die es bisher
|
||||
nirgends gab. Diese Seite hier ist der Einstieg, die andere der
|
||||
Überblick. */
|
||||
const BEREICHE = [
|
||||
{ name: 'Dashboard', phase: 1, rollen: ['admin', 'manager', 'creator', 'scout'], ziel: 'uebersicht.html' },
|
||||
{ name: 'Personen & Zugänge', phase: 1, rollen: ['admin', 'manager'], ziel: 'personen.html' },
|
||||
{ name: 'Creator-Profile', phase: 1, rollen: ['admin', 'manager', 'scout'], ziel: 'profil.html' },
|
||||
{ name: 'Mein Profil', phase: 1, rollen: ['creator'], ziel: 'profil.html' },
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/* ===================================================================
|
||||
Dashboard -- die Gesamtuebersicht je Creator.
|
||||
|
||||
Das Konzept verspricht auf Seite 1 "UEBERSICHT -- alles zentral" und
|
||||
auf Seite 2 "Die Admin-Rolle behaelt den Gesamtueberblick". Genau das
|
||||
fehlte: Die Startseite ist ein Einstieg (was ist dran, wohin gehe
|
||||
ich), aber sie beantwortet nicht "wie steht es um MEINE CREATOR".
|
||||
|
||||
Fuehrt keine eigenen Daten. Jede Zahl kommt aus Aufgaben, Terminen,
|
||||
Bereichen, Profil und Start-Check -- und jede ist anklickbar, damit
|
||||
man von der Zahl direkt zur Sache kommt. Eine Zahl ohne Weg dahin
|
||||
waere nur ein schlechtes Gewissen.
|
||||
=================================================================== */
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const el = (tag, klasse, text) => {
|
||||
const k = document.createElement(tag);
|
||||
if (klasse) k.className = klasse;
|
||||
if (text !== undefined) k.textContent = text;
|
||||
return k;
|
||||
};
|
||||
const melde = (t) => { $('fehler').textContent = t || ''; };
|
||||
|
||||
async function hole(pfad) {
|
||||
const a = await fetch(pfad, { credentials: 'same-origin' });
|
||||
if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
|
||||
return a;
|
||||
}
|
||||
|
||||
const WOCHENTAG = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||
|
||||
const datum = (iso) => (iso ? iso.slice(0, 10).split('-').reverse().join('.') : '');
|
||||
|
||||
/* Ein Datum allein sagt wenig -- "Do, 04.09. · 18:00" ist die Angabe,
|
||||
nach der man den Tag plant. */
|
||||
function zeitpunkt(iso) {
|
||||
if (!iso) return '';
|
||||
const [d, z] = String(iso).split('T');
|
||||
const [j, m, t] = d.split('-');
|
||||
const tag = new Date(Number(j), Number(m) - 1, Number(t));
|
||||
const w = Number.isNaN(tag.getTime()) ? '' : WOCHENTAG[tag.getDay()] + ', ';
|
||||
return `${w}${t}.${m}.` + (z ? ' · ' + z.slice(0, 5) : '');
|
||||
}
|
||||
|
||||
/* Wie lange ist die letzte Anmeldung her? Ab zwei Wochen wird die
|
||||
Angabe gedaempft hervorgehoben -- das ist der Punkt, an dem jemand
|
||||
abzudriften droht. */
|
||||
function herLetzte(iso) {
|
||||
if (!iso) return { text: 'noch nie angemeldet', kalt: true };
|
||||
const tage = Math.floor((Date.now() - Date.parse(iso)) / 86400000);
|
||||
if (!Number.isFinite(tage)) return { text: '' };
|
||||
if (tage <= 0) return { text: 'heute da gewesen' };
|
||||
if (tage === 1) return { text: 'gestern da gewesen' };
|
||||
return { text: `seit ${tage} Tagen nicht da`, kalt: tage >= 14 };
|
||||
}
|
||||
|
||||
function wert(anzahl, name, ziel, warn) {
|
||||
const a = el(ziel ? 'a' : 'span', 'wert');
|
||||
if (ziel) a.href = ziel;
|
||||
if (warn && anzahl > 0) a.dataset.warn = 'ja';
|
||||
if (!anzahl) a.dataset.leer = 'ja';
|
||||
a.append(el('span', 'wert__zahl', String(anzahl)));
|
||||
a.append(el('span', 'wert__name', name));
|
||||
return a;
|
||||
}
|
||||
|
||||
function karte(c, checkGesamt, eigen) {
|
||||
const k = el('article', 'creator');
|
||||
if (c.ueberfaellig > 0 || c.dringend > 0) k.dataset.achtung = 'ja';
|
||||
|
||||
const kopf = el('div', 'creator__kopf');
|
||||
|
||||
const links = el('div', 'creator__wer');
|
||||
links.append(el('h2', 'creator__name', c.name));
|
||||
const unter = el('p', 'creator__unter');
|
||||
const her = herLetzte(c.letzter_login);
|
||||
const teile = [];
|
||||
if (!eigen && c.betreuer) teile.push('betreut von ' + c.betreuer);
|
||||
if (!eigen && !c.betreuer) teile.push('noch niemand zuständig');
|
||||
if (her.text) teile.push(her.text);
|
||||
unter.textContent = teile.join(' · ');
|
||||
if (her.kalt || (!eigen && !c.betreuer)) unter.dataset.kalt = 'ja';
|
||||
links.append(unter);
|
||||
kopf.append(links);
|
||||
|
||||
/* Die einzige Angabe, die nach vorn zeigt -- deshalb steht sie
|
||||
abgesetzt oben rechts und nicht zwischen den Zaehlungen. */
|
||||
const rechts = el('div', 'naechstes');
|
||||
if (c.naechster_termin) {
|
||||
rechts.append(el('span', 'naechstes__schild', 'Nächster Termin'));
|
||||
const a = el('a', 'naechstes__wert', zeitpunkt(c.naechster_termin));
|
||||
a.href = 'kalender.html';
|
||||
rechts.append(a);
|
||||
} else if (c.naechster_review) {
|
||||
rechts.append(el('span', 'naechstes__schild', 'Nächster Review'));
|
||||
const a = el('a', 'naechstes__wert', datum(c.naechster_review));
|
||||
a.href = 'report.html';
|
||||
rechts.append(a);
|
||||
} else {
|
||||
rechts.append(el('span', 'naechstes__schild', 'Nächster Termin'));
|
||||
rechts.append(el('span', 'naechstes__wert naechstes__wert--leer', 'keiner geplant'));
|
||||
}
|
||||
kopf.append(rechts);
|
||||
k.append(kopf);
|
||||
|
||||
const werte = el('div', 'werte');
|
||||
werte.append(wert(c.ueberfaellig, 'überfällig', 'aufgaben.html', true));
|
||||
werte.append(wert(c.dringend, 'dringend', 'bereich.html?b=live', true));
|
||||
werte.append(wert(c.offen, 'offen', 'aufgaben.html'));
|
||||
werte.append(wert(c.arbeit, 'in Arbeit', 'aufgaben.html'));
|
||||
werte.append(wert(c.review, 'im Review', 'aufgaben.html'));
|
||||
werte.append(wert(c.erledigt30, 'erledigt 30 T.', 'aufgaben.html'));
|
||||
k.append(werte);
|
||||
|
||||
/* Start-Check als Balken statt als Zahl -- "4 von 16" liest man,
|
||||
ein Viertel gefuellt sieht man. */
|
||||
const check = el('a', 'fortschritt');
|
||||
check.href = 'startcheck.html';
|
||||
const geprueft = c.check_geprueft || 0;
|
||||
const anteil = checkGesamt ? Math.round((geprueft / checkGesamt) * 100) : 0;
|
||||
if (geprueft >= checkGesamt && checkGesamt) check.dataset.fertig = 'ja';
|
||||
|
||||
const zeile = el('p', 'fortschritt__zeile');
|
||||
zeile.append(el('span', 'fortschritt__schild', 'Start-Check'));
|
||||
zeile.append(el('span', 'fortschritt__text',
|
||||
geprueft ? `${geprueft} von ${checkGesamt} geprüft` : 'noch nicht begonnen'));
|
||||
if (c.check_handlung > 0) {
|
||||
zeile.append(el('span', 'fortschritt__warn',
|
||||
`${c.check_handlung} × Handlungsbedarf`));
|
||||
}
|
||||
check.append(zeile);
|
||||
|
||||
const balken = el('span', 'fortschritt__balken');
|
||||
const fuellung = el('span', 'fortschritt__fuellung');
|
||||
fuellung.style.width = anteil + '%';
|
||||
if (c.check_handlung > 0) fuellung.dataset.warn = 'ja';
|
||||
balken.append(fuellung);
|
||||
check.append(balken);
|
||||
k.append(check);
|
||||
|
||||
if (!c.hat_profil) {
|
||||
const a = el('a', 'creator__luecke', eigen
|
||||
? 'Dein Profil ist noch leer – jetzt ausfüllen'
|
||||
: 'Profil noch nicht ausgefüllt');
|
||||
a.href = 'profil.html';
|
||||
k.append(a);
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
function summenZeigen(liste) {
|
||||
const s = $('summen');
|
||||
s.textContent = '';
|
||||
if (liste.length < 2) { s.hidden = true; return; }
|
||||
const summe = (feld) => liste.reduce((n, c) => n + (c[feld] || 0), 0);
|
||||
const felder = [
|
||||
[liste.length, 'Creator', false],
|
||||
[summe('ueberfaellig'), 'überfällig', true],
|
||||
[summe('dringend'), 'dringende Punkte', true],
|
||||
[summe('offen') + summe('arbeit'), 'offen & in Arbeit', false],
|
||||
[summe('review'), 'im Review', false],
|
||||
[summe('erledigt30'), 'erledigt · 30 Tage', false],
|
||||
];
|
||||
for (const [zahl, name, warn] of felder) {
|
||||
const k = el('div', 'summe');
|
||||
if (warn && zahl > 0) k.dataset.warn = 'ja';
|
||||
k.append(el('span', 'summe__zahl', String(zahl)));
|
||||
k.append(el('span', 'summe__name', name));
|
||||
s.append(k);
|
||||
}
|
||||
s.hidden = false;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
let ich;
|
||||
try {
|
||||
const a = await hole('/workspace/api/ich');
|
||||
if (!a.ok) { location.assign('/workspace/'); return; }
|
||||
ich = await a.json();
|
||||
} catch { return; }
|
||||
$('wer').textContent = ich.name + ' · ' + (ich.rolle_name || ich.rolle);
|
||||
|
||||
let d;
|
||||
try {
|
||||
const a = await hole('/workspace/api/uebersicht/creator');
|
||||
if (!a.ok) { melde('Die Übersicht konnte gerade nicht geladen werden.'); return; }
|
||||
d = await a.json();
|
||||
} catch { return; }
|
||||
|
||||
const ziel = $('liste');
|
||||
ziel.textContent = '';
|
||||
ziel.setAttribute('aria-busy', 'false');
|
||||
|
||||
const creator = d.creator || [];
|
||||
const eigen = !!d.eigen;
|
||||
|
||||
if (eigen) {
|
||||
$('titel').textContent = 'Mein Stand';
|
||||
$('unterzeile').textContent = 'Wo du gerade stehst – aus deinen Aufgaben, Terminen und dem Start-Check.';
|
||||
} else if (!creator.length) {
|
||||
$('unterzeile').textContent = ich.rolle === 'scout'
|
||||
? 'Dir ist noch kein Creator zugeteilt.'
|
||||
: 'Es gibt noch keinen Creator.';
|
||||
ziel.append(el('p', 'leer-hinweis', ich.rolle === 'scout'
|
||||
? 'DogFather legt unter „Personen & Zugänge“ fest, wen du betreust.'
|
||||
: 'Lege unter „Personen & Zugänge“ den ersten Creator an.'));
|
||||
return;
|
||||
} else {
|
||||
$('unterzeile').textContent = creator.length === 1
|
||||
? 'Ein Creator – mit einem Blick sehen, wo es hakt und wo es läuft.'
|
||||
: `${creator.length} Creator – mit einem Blick sehen, wo es hakt und wo es läuft.`;
|
||||
}
|
||||
|
||||
summenZeigen(creator);
|
||||
|
||||
/* Wo etwas brennt, steht oben. Sortiert wird nach dem, was Handlung
|
||||
verlangt -- nicht alphabetisch. Bei Gleichstand dann doch nach
|
||||
Namen, damit die Reihenfolge stabil bleibt. */
|
||||
const sortiert = creator.slice().sort((a, b) =>
|
||||
(b.ueberfaellig + b.dringend) - (a.ueberfaellig + a.dringend)
|
||||
|| String(a.name).localeCompare(String(b.name), 'de'));
|
||||
|
||||
const box = el('div', 'creator-liste');
|
||||
for (const c of sortiert) box.append(karte(c, d.check_gesamt || 16, eigen));
|
||||
ziel.append(box);
|
||||
|
||||
if (d.stand) {
|
||||
/* Der Server schickt UTC. zeitpunkt() darf hier NICHT genommen
|
||||
werden -- das schneidet die Zeichenkette nur auf und zeigte im
|
||||
Sommer zwei Stunden zu wenig an. */
|
||||
const t = new Date(d.stand);
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
const s = $('stand');
|
||||
s.textContent = `Stand: ${p(t.getHours())}:${p(t.getMinutes())} Uhr · `
|
||||
+ 'wird bei jedem Aufruf neu ermittelt';
|
||||
s.hidden = false;
|
||||
}
|
||||
})();
|
||||
})();
|
||||
@@ -7,9 +7,9 @@
|
||||
<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?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -134,8 +134,8 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/aufgaben.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<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?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/automation.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -93,8 +93,8 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/automation.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/automation.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<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?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/bereich.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -98,8 +98,8 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/bereich.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<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?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/calls.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -49,9 +49,9 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/calls.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/ki.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/calls.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<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?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/dateien.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -88,8 +88,8 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/dateien.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
type="image/webp" media="(max-width: 899px)" />
|
||||
|
||||
<link rel="icon" href="assets/img/favicon.png" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="gate">
|
||||
@@ -143,6 +143,6 @@
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/gate.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/gate.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<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?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/kalender.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -94,8 +94,8 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kalender.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<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?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/personen.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -93,8 +93,8 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/personen.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/personen.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<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?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/profil.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -127,8 +127,8 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/profil.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/profil.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<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?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/report.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -86,9 +86,9 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/report.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/ki.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/report.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<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?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/scouting.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -124,8 +124,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/scouting.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<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?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -56,8 +56,8 @@
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/start.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/start.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
<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?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/startcheck.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/startcheck.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -65,9 +65,9 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/ki.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/startcheck.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/ki.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/startcheck.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Dashboard · 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?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/uebersicht.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
|
||||
<header class="kopfleiste">
|
||||
<button type="button" class="zurueck-knopf" id="zurueck" hidden>
|
||||
<svg class="zurueck-knopf__pfeil" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M14.5 5.5 8 12l6.5 6.5" />
|
||||
</svg>
|
||||
<span class="zurueck-knopf__text">Zurück</span>
|
||||
</button>
|
||||
<p class="marke"><a class="zurueck" href="start.html">Creator Workspace</a> · Dashboard</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">Alles zentral</p>
|
||||
<h1 class="titel" id="titel">Dashboard</h1>
|
||||
<p class="unterzeile" id="unterzeile">
|
||||
Eine Karte je Creator – mit einem Blick sehen, wo es hakt und wo es läuft.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="fehler" id="fehler" role="alert" aria-live="polite"></p>
|
||||
|
||||
<!-- Summen über alle sichtbaren Creator. Steht oben, weil die Frage
|
||||
"wie steht es insgesamt" vor der Frage "bei wem" kommt. -->
|
||||
<section class="summen" id="summen" hidden></section>
|
||||
|
||||
<div id="liste" aria-busy="true"><p class="leise">Übersicht wird geladen …</p></div>
|
||||
|
||||
<p class="stand" id="stand" hidden></p>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/uebersicht.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -7,10 +7,10 @@
|
||||
<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?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/wissen.css?v=202608311245" />
|
||||
<link rel="stylesheet" href="assets/css/gate.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/start.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/aufgaben.css?v=202608311315" />
|
||||
<link rel="stylesheet" href="assets/css/wissen.css?v=202608311315" />
|
||||
</head>
|
||||
|
||||
<body class="start">
|
||||
@@ -170,8 +170,8 @@
|
||||
|
||||
</main>
|
||||
|
||||
<script src="assets/js/wahl.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wissen.js?v=202608311245" defer></script>
|
||||
<script src="assets/js/wahl.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/kopf.js?v=202608311315" defer></script>
|
||||
<script src="assets/js/wissen.js?v=202608311315" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user