Verwaltung: Übersicht als Startansicht, Reiter unter den Titel

Die Reiter standen neben dem Titel. Das las sich wie eine einzige lange
Zeile, in der der Titel nur der erste von sechs Knöpfen zu sein schien --
man sah nicht auf einen Blick, in welchem Bereich man war. Jetzt zwei
Zeilen: oben WO man ist, darunter WOHIN man kann.

Die Verwaltung öffnete bisher mit der Anfragenliste. Eine Liste ist eine
Ablage: Sie zeigt, WAS es gibt, nicht was zu TUN ist. Neu ist ein
Cockpit, das in drei Stufen antwortet -- was auf mich wartet, was beim
Kunden liegt, wie es ums Geld steht -- und dann laufende Projekte mit
Fortschritt sowie den Verlauf.

Zwei Grundsätze machen die Zahlen brauchbar: Getrennt nach 'wartet auf
mich' und 'wartet auf den Kunden' (zwölf offene Punkte sind entspannt,
wenn elf beim Kunden liegen). Und das ALTER färbt, nicht die Menge --
vier neue Anfragen sind kein Problem, eine seit sechs Tagen liegende
schon. Ein offener Widerruf ist immer rot, weil eine gesetzliche Frist
läuft.

Jede Kachel ist ein echter <button> und führt in den passenden Reiter.
Farbe ist nie der einzige Träger: Neben jedem farbigen Zustand steht der
Text ('älteste seit 8 Tagen').

Drei Fehler dabei gefunden und behoben:
- Der Titel wurde nur beim Klicken gesetzt. Frisch geladen zeigte die
  Seite das Cockpit, während darüber noch 'Projektanfragen' stand. Die
  Zuordnung Reiter->Titel liegt jetzt ausserhalb des Klick-Zuhörers.
- '.wd h2' überstimmte '.vw-ub-h': 44px Überschrift über 33px Zahl, die
  Seite las sich wie ein Plakat. Dieselbe Spezifitätsfalle wie früher
  bei '.wd a'.
- Eine Antwort mit ok:true aber ohne Inhalt riss die ganze Verwaltung
  mit. Wird jetzt abgefangen -- ein halber Server ist ein realistischer
  Fall.

Geprüft: 22 Serverprüfungen gegen eine echte Datenbank (inkl. vier
Fällen, die belegen, dass die Rechteprüfung wirklich greift), 56 im
Browser auf Computer und Handy, 69 im bestehenden Verwaltungstest,
0 Fundstellen im Design-/WCAG-Test, 5 im Farbsystemtest.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-23 19:21:12 +02:00
co-authored by Claude Opus 5
parent 4fe66e1e0e
commit c1cb51884f
8 changed files with 1057 additions and 24 deletions
+1
View File
@@ -20,3 +20,4 @@ server/node_modules/
# kollidieren und den Deploy blockieren -- genau das ist gerade passiert.
server/tmp-lokal.mjs
server/package-lock.json
bild-*.png
+245
View File
@@ -0,0 +1,245 @@
/* =====================================================================
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();
const r = document.querySelector(".vw-tabs").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;
+12
View File
@@ -151,6 +151,18 @@ for (const [wo, breite, hoehe] of [["Computer", 1440, 950], ["Handy", 390, 844]]
anzahl: 3, ungelesen: 1, letzte: new Date(Date.now() - 1800000).toISOString(),
letzter_text: "Nicht vergessen: Rechnung erst nach Abnahme.", letzter_autor: "dogfather" }] } });
}
/* Die Uebersicht ist seit dem 23.08.2026 die Startansicht und wird
deshalb bei JEDEM Laden abgerufen. Ohne eigene Ersatzantwort fiel
sie in den Auffang unten und bekam ein blankes {ok:true} -- was
einen Fehler ausloeste, der als "keine Fehler im Protokoll"
fehlschlug, ohne zu verraten, woher er kam. */
if (u.includes("/admin/uebersicht")) return route.fulfill({ json: { ok: true,
beiMir: { anfragen: { n: 0 }, nachrichten: { n: 0 }, wuensche: { n: 0 },
widerrufe: { n: 0 }, projekte: 0 },
beiKunde: { projekte: 0, aufgaben: 0, zahlungen: { n: 0, summe: 0 }, zahlungenText: "0,00 €" },
geld: { monat: { n: 0, brutto: 0, netto: 0, bruttoText: "0,00 €", nettoText: "0,00 €" },
gesamt: { n: 0, bruttoText: "0,00 €" }, gebuehrenText: "0,00 €" },
projekte: [], verlauf: [], bestand: {} } });
if (u.includes("/admin/anfragen")) return route.fulfill({ json: { ok: true, anfragen: [] } });
if (u.includes("/admin/kunden")) return route.fulfill({ json: { ok: true, kunden: [] } });
return route.fulfill({ json: { ok: true } });
+2 -1
View File
@@ -124,7 +124,7 @@ import {
import {
zahlungAnsehen, zahlungStarten, zahlungAbschliessen,
paypalWebhook, zahlungAnlegen, zahlungenListe,
einstellungenLesen, einstellungenSchreiben, einstellungenPruefen,
einstellungenLesen, einstellungenSchreiben, einstellungenPruefen, uebersicht,
} from "./routes/webdesign-zahlungen.js";
/* PayPal-Zugangsdaten koennen verschluesselt in der Datenbank liegen,
damit Filipe sie in seiner Verwaltung eintragen kann statt per SSH.
@@ -346,6 +346,7 @@ app.post("/webdesign/portal/zahlung/:id/start", wrap(zahlungStarten));
app.post("/webdesign/portal/zahlung/:id/abschluss", wrap(zahlungAbschliessen));
app.get("/webdesign/admin/zahlungen", wrap(zahlungenListe));
app.post("/webdesign/admin/zahlungen", wrap(zahlungAnlegen));
app.get("/webdesign/admin/uebersicht", wrap(uebersicht));
app.get("/webdesign/admin/einstellungen", wrap(einstellungenLesen));
app.post("/webdesign/admin/einstellungen", wrap(einstellungenSchreiben));
app.post("/webdesign/admin/einstellungen/pruefen", wrap(einstellungenPruefen));
@@ -577,3 +577,150 @@ export async function einstellungenPruefen(req, res) {
}, 400);
}
}
/* =====================================================================
ÜBERSICHT — das Cockpit
Die Verwaltung öffnete bisher mit einer Liste von Anfragen. Eine Liste
ist eine Ablage, keine Auskunft: Sie zeigt, WAS es gibt, nicht was zu
TUN ist. Wer morgens hereinkommt, muss durch fünf Reiter klicken, um
sich ein Bild zu machen — und übersieht dabei zuverlässig das eine,
das seit vier Tagen liegt.
Diese Antwort liefert alle Zahlen in EINER Abfrage. Fünf einzelne
Abrufe würden die Seite ruckelnd aufbauen und wären beim Wechsel
zwischen den Reitern jedes Mal fällig.
ZWEI GRUNDSÄTZE, DIE DIE ZAHLEN BRAUCHBAR MACHEN
1. GETRENNT NACH "wartet auf mich" und "wartet auf den Kunden".
Eine Gesamtzahl offener Vorgänge sagt nichts: Zwölf offene Punkte
sind entspannt, wenn elf davon beim Kunden liegen — und
alarmierend, wenn alle elf bei mir liegen.
2. DAS ALTER ZÄHLT, NICHT DIE MENGE. "Vier neue Anfragen" ist eine
Mengenangabe. "Die älteste wartet seit sechs Tagen" ist eine
Aussage über Zuverlässigkeit — und genau daran misst ein Kunde,
ob man sein Geld wert ist.
===================================================================== */
export async function uebersicht(req, res) {
const p = await darf(req, "WD_ANFRAGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const eine = (sql, ...w) => db.prepare(sql).get(...w) || {};
const viele = (sql, ...w) => db.prepare(sql).all(...w);
/* ---- Was auf MICH wartet ---- */
const anfragenOffen = eine(
`SELECT COUNT(*) AS n, MIN(erstellt_am) AS aeltestes
FROM wd_anfragen WHERE archiviert = 0 AND status IN ('neu','rueckfrage')`
);
const nachrichtenOffen = eine(
`SELECT COUNT(*) AS n, MIN(erstellt_am) AS aeltestes
FROM wd_postfach WHERE gelesen_admin = 0 AND autor = 'kunde'`
);
const wuenscheOffen = eine(
`SELECT COUNT(*) AS n, MIN(erstellt_am) AS aeltestes
FROM wd_aenderungen WHERE status IN ('eingereicht','geprueft')`
);
const widerrufeOffen = eine(
`SELECT COUNT(*) AS n, MIN(eingegangen_am) AS aeltestes
FROM wd_widerrufe WHERE status = 'eingegangen'`
);
const projekteBeiMir = eine(
`SELECT COUNT(*) AS n FROM wd_projekte
WHERE archiviert = 0 AND wartet_auf = 'dogfather'
AND status NOT IN ('uebergeben','abgebrochen')`
);
/* ---- Was auf den KUNDEN wartet ---- */
const projekteBeiKunde = eine(
`SELECT COUNT(*) AS n FROM wd_projekte
WHERE archiviert = 0 AND wartet_auf = 'kunde'
AND status NOT IN ('uebergeben','abgebrochen')`
);
const aufgabenBeiKunde = eine(
`SELECT COUNT(*) AS n FROM wd_aufgaben
WHERE wer_dran = 'kunde' AND status NOT IN ('erledigt','entfaellt')
AND nicht_enthalten = 0`
);
const zahlungenOffen = eine(
`SELECT COUNT(*) AS n, COALESCE(SUM(betrag_cent),0) AS summe, MIN(erstellt_am) AS aeltestes
FROM wd_zahlungen WHERE status IN ('offen','freigegeben')`
);
/* ---- Geld ---- */
const monatsAnfang = new Date();
monatsAnfang.setDate(1);
monatsAnfang.setHours(0, 0, 0, 0);
const bezahltMonat = eine(
`SELECT COUNT(*) AS n, COALESCE(SUM(betrag_cent),0) AS brutto,
COALESCE(SUM(netto_cent),0) AS netto,
COALESCE(SUM(gebuehr_cent),0) AS gebuehr
FROM wd_zahlungen WHERE status = 'bezahlt' AND bezahlt_am >= ?`,
monatsAnfang.toISOString()
);
const bezahltGesamt = eine(
`SELECT COUNT(*) AS n, COALESCE(SUM(betrag_cent),0) AS brutto
FROM wd_zahlungen WHERE status = 'bezahlt'`
);
/* ---- Laufende Projekte mit ihrem Stand ---- */
const projekte = viele(
`SELECT p.id, p.nummer, p.titel, p.status, p.wartet_auf, p.richttermin,
k.name AS kunde_name,
(SELECT COUNT(*) FROM wd_aufgaben a
WHERE a.projekt_id = p.id AND a.status <> 'entfaellt') AS aufg_gesamt,
(SELECT COUNT(*) FROM wd_aufgaben a
WHERE a.projekt_id = p.id AND a.status = 'erledigt') AS aufg_fertig
FROM wd_projekte p JOIN wd_kunden k ON k.id = p.kunde_id
WHERE p.archiviert = 0 AND p.status NOT IN ('uebergeben','abgebrochen')
ORDER BY p.erstellt_am DESC LIMIT 8`
);
/* ---- Was zuletzt geschah ---- */
const verlaufsListe = viele(
`SELECT bereich, aktion, wer, erstellt_am
FROM wd_verlauf ORDER BY erstellt_am DESC LIMIT 12`
);
/* ---- Bestand ---- */
const bestand = {
kunden: eine(`SELECT COUNT(*) AS n FROM wd_kunden WHERE archiviert = 0`).n,
projekte: eine(`SELECT COUNT(*) AS n FROM wd_projekte WHERE archiviert = 0`).n,
anfragenGesamt: eine(`SELECT COUNT(*) AS n FROM wd_anfragen WHERE archiviert = 0`).n,
};
return json(res, {
ok: true,
beiMir: {
anfragen: anfragenOffen,
nachrichten: nachrichtenOffen,
wuensche: wuenscheOffen,
widerrufe: widerrufeOffen,
projekte: projekteBeiMir.n,
},
beiKunde: {
projekte: projekteBeiKunde.n,
aufgaben: aufgabenBeiKunde.n,
zahlungen: zahlungenOffen,
zahlungenText: centZuText(zahlungenOffen.summe || 0),
},
geld: {
monat: { ...bezahltMonat, bruttoText: centZuText(bezahltMonat.brutto || 0),
nettoText: centZuText(bezahltMonat.netto || 0) },
gesamt: { ...bezahltGesamt, bruttoText: centZuText(bezahltGesamt.brutto || 0) },
/* Ehrlich beziffern, was PayPal einbehalten hat. Der Unterschied
zwischen Umsatz und dem, was ankommt, gehoert sichtbar gemacht --
sonst rechnet man am Jahresende mit der falschen Zahl. */
gebuehrenText: centZuText(bezahltMonat.gebuehr || 0),
},
projekte,
verlauf: verlaufsListe,
bestand,
paypal: {
eingerichtet: paypal.istEingerichtet("einmal"),
live: paypal.istLive(),
},
});
}
+167
View File
@@ -0,0 +1,167 @@
/* Prüft das Cockpit gegen eine echte Datenbank.
Die Übersicht besteht fast nur aus SQL. Ein Tippfehler in einem
Spaltennamen wirft erst beim Aufruf — also erst, wenn Filipe die Seite
öffnet und eine leere Verwaltung sieht. Deshalb läuft hier jede
einzelne Abfrage einmal gegen ein frisch angelegtes Schema.
Zusätzlich wird die Trennung "wartet auf mich" / "wartet auf den
Kunden" mit echten Datensätzen belegt. Genau das ist der Wert der
Seite -- eine Zahl, die beides zusammenwirft, ist wertlos.
*/
import { mkdtempSync, rmSync, writeSync, readdirSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomBytes, createHmac } from "node:crypto";
const L = (...a) => writeSync(1, a.join(" ") + "\n"); // better-sqlite3 bricht am Ende ab
const tmp = mkdtempSync(join(tmpdir(), "wd-ueb-"));
process.env.DB_PATH = join(tmp, "t.db");
process.env.ENCRYPTION_KEY = randomBytes(32).toString("base64");
process.env.WEBDESIGN_API_SECRET = randomBytes(24).toString("hex");
const { initDb, db } = await import("./db.js");
initDb();
const geheim = await import("./lib/webdesign-geheimnisse.js");
await geheim.geheimnisseLaden();
const { uebersicht } = await import("./routes/webdesign-zahlungen.js");
let ok = 0, fehler = 0;
const pruefe = (n, b, z) => b ? (ok++, L(" ok " + n))
: (fehler++, L(" FEHL " + n + (z !== undefined ? " -> " + z : "")));
/* Ein ECHTER Ausweis, nicht an der Rechtepruefung vorbei.
Es waere bequemer gewesen, darf() im Test zu ueberschreiben. Dann
wuerde der Test aber auch dann gruen bleiben, wenn der Endpunkt seine
Rechtepruefung ganz verliert -- und genau das ist der Fehler, den man
auf keinen Fall uebersehen will. Deshalb wird hier ein gueltiger
Ausweis mit demselben Verfahren erzeugt, das die Zugangswand benutzt. */
function ausweis(rolle = "dogfather", gueltigMs = 60_000) {
const nutzlast = Buffer.from(JSON.stringify({
bereich: "wd-admin", rolle, exp: Date.now() + gueltigMs,
})).toString("base64url");
const sig = createHmac("sha256", process.env.WEBDESIGN_API_SECRET)
.update(nutzlast).digest("base64url");
return nutzlast + "." + sig;
}
/* Ein technisch einwandfrei unterschriebener Ausweis, der aber fuer die
oeffentliche Zugangswand ausgestellt wurde. Er muss abgelehnt werden --
sonst kaeme jeder Besucher, der die Zugangswand passiert hat, auch in
die Verwaltung. */
function fremderAusweis() {
const nutzlast = Buffer.from(JSON.stringify({
bereich: "wd-gate", rolle: "dogfather", exp: Date.now() + 60_000,
})).toString("base64url");
const sig = createHmac("sha256", process.env.WEBDESIGN_API_SECRET)
.update(nutzlast).digest("base64url");
return nutzlast + "." + sig;
}
const req = { headers: { authorization: "Bearer " + ausweis() }, cookies: {} };
let antwort = null;
const res = { status(){return this;}, json(o){antwort=o;return this;},
setHeader(){}, end(){}, writeHead(){return this;} };
/* --- Daten anlegen --- */
const jetzt = new Date().toISOString();
const alt = new Date(Date.now() - 6 * 864e5).toISOString();
db.prepare(`INSERT INTO wd_kunden (id,email,name,erstellt_am) VALUES ('k1','[email protected]','Testkunde',?)`).run(jetzt);
const anf = db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,erstellt_am)
VALUES (?,?,?,?,'start',?,?)`);
anf.run('a1','A-1','X','[email protected]','neu',alt);
anf.run('a2','A-2','Y','[email protected]','erledigt',jetzt);
const prj = db.prepare(`INSERT INTO wd_projekte (id,nummer,kunde_id,titel,paket,status,wartet_auf,preis_cent,erstellt_am)
VALUES (?,?,'k1',?,'start','bau',?,?,?)`);
prj.run('p1','P-1','Bei mir','dogfather',100000,jetzt);
prj.run('p2','P-2','Beim Kunden','kunde',50000,jetzt);
const auf = db.prepare(`INSERT INTO wd_aufgaben (id,projekt_id,kunde_id,titel,status,wer_dran,erstellt_am)
VALUES (?,'p1','k1',?,?,?,?)`);
auf.run('t1','Fertig','erledigt','dogfather',jetzt);
auf.run('t2','Offen beim Kunden','offen','kunde',jetzt);
db.prepare(`INSERT INTO wd_postfach (id,kunde_id,autor,text,gelesen_admin,erstellt_am)
VALUES ('n1','k1','kunde','Hallo',0,?)`).run(alt);
db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,art,zweck_text,betrag_cent,status,erstellt_am)
VALUES ('z1','Z-1','k1','anzahlung','Anzahlung',30000,'offen',?)`).run(jetzt);
db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,art,zweck_text,betrag_cent,netto_cent,gebuehr_cent,status,bezahlt_am,erstellt_am)
VALUES ('z2','Z-2','k1','rest','Rest',70000,67500,2500,'bezahlt',?,?)`).run(jetzt, jetzt);
L("\n1. LAEUFT DIE ABFRAGE UEBERHAUPT DURCH?");
try {
await uebersicht(req, res);
pruefe("kein SQL-Fehler -- jede Spalte existiert wirklich", true);
} catch (e) {
pruefe("kein SQL-Fehler -- jede Spalte existiert wirklich", false, e.message);
L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
process.exitCode = 1;
rmSync(tmp, { recursive: true, force: true });
process.exit(1);
}
pruefe("es kommt eine Antwort zurueck", !!antwort, JSON.stringify(antwort).slice(0, 120));
if (antwort && antwort.ok) {
L("\n2. WAS AUF MICH WARTET");
pruefe("die erledigte Anfrage wird NICHT mitgezaehlt",
antwort.beiMir.anfragen.n === 1, antwort.beiMir.anfragen.n);
pruefe("das Alter der aeltesten Anfrage kommt mit",
antwort.beiMir.anfragen.aeltestes === alt, antwort.beiMir.anfragen.aeltestes);
pruefe("die ungelesene Kundennachricht zaehlt",
antwort.beiMir.nachrichten.n === 1, antwort.beiMir.nachrichten.n);
pruefe("ein Projekt wartet auf mich", antwort.beiMir.projekte === 1, antwort.beiMir.projekte);
L("\n3. WAS AUF DEN KUNDEN WARTET -- sauber getrennt");
pruefe("das andere Projekt wartet auf den Kunden",
antwort.beiKunde.projekte === 1, antwort.beiKunde.projekte);
pruefe("die erledigte Aufgabe zaehlt nicht mit",
antwort.beiKunde.aufgaben === 1, antwort.beiKunde.aufgaben);
pruefe("die offene Zahlung erscheint mit Betrag",
antwort.beiKunde.zahlungen.n === 1 && /300/.test(antwort.beiKunde.zahlungenText),
antwort.beiKunde.zahlungenText);
pruefe("die BEZAHLTE Zahlung erscheint hier NICHT",
antwort.beiKunde.zahlungen.summe === 30000, antwort.beiKunde.zahlungen.summe);
L("\n4. GELD -- Gebuehr aus der Spalte, nicht geschaetzt");
pruefe("Umsatz im Monat stimmt", /700/.test(antwort.geld.monat.bruttoText), antwort.geld.monat.bruttoText);
pruefe("die PayPal-Gebuehr wird ehrlich ausgewiesen",
/25/.test(antwort.geld.gebuehrenText), antwort.geld.gebuehrenText);
pruefe("das Netto ist weniger als das Brutto",
antwort.geld.monat.netto < antwort.geld.monat.brutto,
antwort.geld.monat.netto + " < " + antwort.geld.monat.brutto);
L("\n5. PROJEKTLISTE MIT FORTSCHRITT");
const p1 = antwort.projekte.find((p) => p.id === "p1");
pruefe("das Projekt taucht auf", !!p1);
pruefe("der Kundenname haengt dran", p1 && p1.kunde_name === "Testkunde", p1 && p1.kunde_name);
pruefe("Aufgaben werden gezaehlt: 1 von 2 fertig",
p1 && p1.aufg_gesamt === 2 && p1.aufg_fertig === 1,
p1 && p1.aufg_fertig + "/" + p1.aufg_gesamt);
L("\n6. BESTAND");
pruefe("Kunden gezaehlt", antwort.bestand.kunden === 1, antwort.bestand.kunden);
pruefe("Projekte gezaehlt", antwort.bestand.projekte === 2, antwort.bestand.projekte);
} else {
pruefe("Zugriff erlaubt (sonst kann nichts geprueft werden)", false,
antwort && antwort.error);
}
L("\n7. DIE RECHTEPRUEFUNG GREIFT WIRKLICH");
/* Ohne diese drei Faelle koennte der Endpunkt seine Rechtepruefung
verlieren, ohne dass ein Test rot wird -- die Zahlen oben kaemen ja
weiterhin richtig heraus. Der gefaehrlichste Fehler waere also genau
der, den die uebrigen Pruefungen nicht sehen. */
for (const [name, kopf] of [
["ohne Ausweis kommt nichts heraus", {}],
["mit gefaelschter Unterschrift kommt nichts heraus",
{ authorization: "Bearer " + ausweis().split(".")[0] + ".XXXX" }],
["mit abgelaufenem Ausweis kommt nichts heraus",
{ authorization: "Bearer " + ausweis("dogfather", -1000) }],
["ein Ausweis fuer einen ANDEREN Bereich zaehlt nicht",
{ authorization: "Bearer " + fremderAusweis() }],
]) {
antwort = null;
await uebersicht({ headers: kopf, cookies: {} }, res);
pruefe(name, antwort && antwort.ok === false, JSON.stringify(antwort).slice(0, 80));
}
L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
process.exitCode = fehler ? 1 : 0;
rmSync(tmp, { recursive: true, force: true });
+1 -1
View File
@@ -34,7 +34,7 @@
Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den
Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten
Stand fest. */
const CACHE_NAME = "dogfather-webdesign-v20";
const CACHE_NAME = "dogfather-webdesign-v21";
/* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE
HTML-Datei in dieser Liste. */
+482 -22
View File
@@ -28,14 +28,202 @@
.vw-login { max-width: 420px; margin: clamp(2rem,8vh,5rem) auto; }
/* ---------- Kopfleiste ---------- */
/* Zwei Zeilen statt einer: oben WO man ist, darunter WOHIN man kann.
Vorher standen Titel und Reiter nebeneinander. Das liest sich wie
eine einzige lange Zeile, in der der Titel nur der erste von sechs
Knöpfen zu sein scheint -- man sieht nicht mehr auf einen Blick, in
welchem Bereich man gerade ist. Getrennt beantwortet die erste Zeile
"wo bin ich", die zweite "wo kann ich hin". */
.vw-leiste {
display: flex; flex-wrap: wrap; align-items: center; gap: .8rem;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: .6rem 1rem;
margin-bottom: 1.2rem;
}
.vw-leiste h1 { margin: 0; font-size: clamp(1.4rem, 3.4vw, 2rem); }
.vw-wer { margin-left: auto; display: flex; align-items: center; gap: .6rem; font-size: .9rem; color: var(--wd-text-soft); }
.vw-leiste h1 {
margin: 0; font-size: clamp(1.4rem, 3.4vw, 2rem);
grid-column: 1; grid-row: 1;
}
.vw-leiste .vw-wer { grid-column: 2; grid-row: 1; }
/* Die Reiter über die volle Breite darunter. */
.vw-leiste .vw-tabs {
grid-column: 1 / -1; grid-row: 2;
padding-top: .2rem;
}
@media (max-width: 560px) {
/* Auf dem Handy darf der Name weichen -- der Platz gehört dem Titel. */
.vw-leiste { grid-template-columns: 1fr; }
.vw-leiste .vw-wer { grid-column: 1; grid-row: 3; justify-content: flex-start; }
.vw-leiste .vw-tabs { grid-row: 2; }
}
.vw-wer { display: flex; justify-content: flex-end; align-items: center; gap: .6rem; font-size: .9rem; color: var(--wd-text-soft); }
/* ================================================================
ÜBERSICHT — das Cockpit
Gestalterische Grundregel hier: Ruhe als Normalzustand. Eine
Verwaltung, in der alles leuchtet, hat keine Dringlichkeitsstufen
mehr — man sieht überall Farbe und deshalb nirgends ein Signal.
Deshalb sind die Kacheln standardmäßig zurückhaltend, und nur was
wirklich liegen geblieben ist, bekommt Farbe.
Farbe ist außerdem NIE der einzige Träger einer Aussage. Neben
jedem farbigen Zustand steht ein Text ("älteste seit 6 Tagen"),
damit die Information auch ankommt, wenn man Farben nicht
unterscheiden kann (WCAG 1.4.1).
================================================================ */
/* Zwei Klassen, nicht eine -- und das ist kein Schoenheitsfehler.
Mit `.vw-ub-h` (eine Klasse) gewinnt `.wd h2` (Klasse + Element),
und die Zwischenueberschriften erschienen in 44px Schaugroesse:
Sie waren doppelt so gross wie die Zahlen, um die es geht, und die
Seite las sich wie ein Plakat statt wie ein Arbeitsplatz. Dieselbe
Falle wie frueher bei `.wd a` und `.wd .wd-btn--haupt`. */
.wd .vw-ub-h {
font-size: 1.05rem; font-weight: 650;
margin: 1.9rem 0 .75rem;
letter-spacing: .01em; color: var(--wd-text);
/* Eine leise Linie darunter gliedert die drei Bloecke, ohne sie mit
Kaesten zu umranden -- Rahmen um Rahmen wuerde unruhig. */
padding-bottom: .35rem;
border-bottom: 1px solid rgb(255 255 255 / .07);
}
.wd .vw-ub-h:first-child { margin-top: 0; }
.vw-ub-hinweis { margin: -.4rem 0 .8rem; max-width: 62ch; }
.vw-ub-gitter {
display: grid; gap: .7rem;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
margin-bottom: .4rem;
}
.vw-ub-kachel {
display: flex; flex-direction: column; align-items: flex-start; gap: .18rem;
text-align: left; width: 100%;
padding: .95rem 1rem;
border-radius: var(--wd-radius-m, 14px);
border: 1px solid rgb(var(--wd-blau-rgb) / .16);
background: var(--wd-flaeche-2, #16191d);
color: var(--wd-text);
cursor: pointer;
font: inherit;
box-shadow: var(--wd-hoehe-1);
transition: transform .16s ease, border-color .16s ease, background-color .16s ease;
}
.vw-ub-kachel:hover:not(:disabled) {
transform: translateY(-2px);
border-color: rgb(var(--wd-blau-rgb) / .42);
}
/* Sichtbarer Fokus, weil das hier eine Tastatur-bedienbare Fläche ist
und die Standardmarkierung auf dunklem Grund untergeht. */
.vw-ub-kachel:focus-visible {
outline: 2px solid rgb(var(--wd-blau-rgb) / .95);
outline-offset: 2px;
}
.vw-ub-kachel:disabled { cursor: default; }
.vw-ub-zahl {
font-size: clamp(1.6rem, 4vw, 2.05rem); font-weight: 700; line-height: 1.05;
/* Ziffern in gleicher Breite: Sonst springt die Zahl beim
Aktualisieren seitlich, weil eine 1 schmaler ist als eine 8. */
font-variant-numeric: tabular-nums;
color: rgb(var(--wd-blau-rgb));
}
.vw-ub-titel { font-size: .92rem; font-weight: 600; }
.vw-ub-unten { font-size: .8rem; color: var(--wd-text-soft); }
/* Nichts zu tun — die Kachel tritt zurück, statt zu verschwinden.
Weg wäre falsch: "0 offene Anfragen" ist eine Aussage, und wenn die
Kachel fehlt, weiß man nicht, ob geprüft wurde. */
.vw-ub-kachel--leer { opacity: .62; border-color: rgb(255 255 255 / .07); }
.vw-ub-kachel--leer .vw-ub-zahl { color: var(--wd-text-soft); }
/* Zwei Stufen statt einer. Ein einziger Warnzustand zwingt dazu, alles
Liegengebliebene gleich laut zu machen — dann geht das wirklich
Dringende im Dauerwarnen unter. */
.vw-ub-kachel--warn {
border-color: rgb(233 196 106 / .48);
background: linear-gradient(rgb(233 196 106 / .05), rgb(233 196 106 / .05)), var(--wd-flaeche-2, #16191d);
}
.vw-ub-kachel--warn .vw-ub-zahl { color: #e9c46a; }
.vw-ub-kachel--rot {
border-color: rgb(231 130 120 / .55);
background: linear-gradient(rgb(231 130 120 / .06), rgb(231 130 120 / .06)), var(--wd-flaeche-2, #16191d);
}
/* Gedämpftes Korallrot statt reinem Rot: auf dunklem Grund ist volles
Rot blendend und schlecht lesbar — dieser Ton hält den Kontrast und
bleibt trotzdem ruhig genug fürs lange Hinsehen. */
.vw-ub-kachel--rot .vw-ub-zahl { color: #e78278; }
/* ---- Projekte ---- */
.vw-ub-projekt {
display: block; width: 100%; text-align: left;
padding: .85rem 1rem; margin-bottom: .55rem;
border-radius: var(--wd-radius-m, 14px);
border: 1px solid rgb(255 255 255 / .08);
background: var(--wd-flaeche-2, #16191d);
color: var(--wd-text); cursor: pointer; font: inherit;
transition: border-color .16s ease, transform .16s ease;
}
.vw-ub-projekt:hover { border-color: rgb(var(--wd-blau-rgb) / .38); transform: translateY(-1px); }
.vw-ub-projekt:focus-visible { outline: 2px solid rgb(var(--wd-blau-rgb) / .95); outline-offset: 2px; }
.vw-ub-pkopf {
display: flex; flex-wrap: wrap; align-items: baseline;
justify-content: space-between; gap: .5rem;
}
.vw-ub-pname { font-weight: 650; font-size: .98rem; }
.vw-ub-pwer {
font-size: .74rem; padding: .12rem .5rem; border-radius: 999px;
background: rgb(255 255 255 / .07); color: var(--wd-text-soft);
white-space: nowrap;
}
/* Nur das, was bei MIR liegt, wird hervorgehoben. Was beim Kunden
liegt, ist keine Aufgabe und braucht keine Aufmerksamkeit. */
.vw-ub-pwer--ich {
background: rgb(var(--wd-blau-rgb) / .17);
color: rgb(var(--wd-blau-rgb));
}
.vw-ub-pkunde { display: block; font-size: .8rem; color: var(--wd-text-soft); margin-top: .12rem; }
.vw-ub-balken {
display: block; height: 6px; margin: .55rem 0 .3rem;
border-radius: 999px; background: rgb(255 255 255 / .08); overflow: hidden;
}
.vw-ub-balken-voll {
display: block; height: 100%; border-radius: 999px;
background: linear-gradient(90deg, rgb(var(--wd-blau-rgb) / .75), rgb(var(--wd-gold-rgb) / .8));
transition: width .5s ease;
}
.vw-ub-punten { display: block; font-size: .78rem; color: var(--wd-text-soft); font-variant-numeric: tabular-nums; }
/* ---- Verlauf ---- */
.vw-ub-verlauf { list-style: none; margin: 0; padding: 0; }
.vw-ub-verlauf li {
display: grid; grid-template-columns: auto 1fr auto; gap: .2rem .8rem;
align-items: baseline;
padding: .42rem .2rem;
border-bottom: 1px solid rgb(255 255 255 / .055);
font-size: .85rem;
}
.vw-ub-verlauf li:last-child { border-bottom: 0; }
.vw-ub-vzeit { color: var(--wd-text-soft); font-variant-numeric: tabular-nums; white-space: nowrap; }
.vw-ub-vwas { color: var(--wd-text); }
.vw-ub-vwer { color: var(--wd-text-soft); font-size: .78rem; white-space: nowrap; }
@media (max-width: 560px) {
/* Auf dem Handy untereinander statt in drei Spalten — sonst wird die
mittlere Spalte so schmal, dass jede Zeile dreifach umbricht. */
.vw-ub-verlauf li { grid-template-columns: 1fr; }
.vw-ub-vwer { white-space: normal; }
}
@media (prefers-reduced-motion: reduce) {
.vw-ub-kachel, .vw-ub-projekt, .vw-ub-balken-voll { transition: none; }
.vw-ub-kachel:hover:not(:disabled), .vw-ub-projekt:hover { transform: none; }
}
/* ---------- Kennzahlen, gehobene Fassung ----------
Die erste Fassung war eine Reihe stiller Zahlen. Zahlen, die man
nur anschauen kann, sind Dekoration. Diese hier sind Werkzeuge:
@@ -323,7 +511,7 @@
.vw-notiz-kopf { font-size: .76rem; color: var(--wd-text-soft); margin-bottom: .3rem; }
.vw-notiz p { margin: 0; font-size: .93rem; white-space: pre-wrap; }
.vw-tabs { display: flex; gap: .4rem; }
.vw-tabs { display: flex; flex-wrap: wrap; gap: .4rem; }
.vw-link-anzeige {
padding: 1rem; border-radius: 14px;
background: rgb(var(--wd-gold-rgb) / .08); border: 1px solid rgb(var(--wd-gold-rgb) / .35);
@@ -633,9 +821,11 @@
<!-- ============ ARBEITSBEREICH ============ -->
<div id="vw-bereich" hidden>
<div class="vw-leiste">
<h1 id="vw-titel">Projektanfragen</h1>
<h1 id="vw-titel">Übersicht</h1>
<div class="vw-tabs">
<button type="button" class="vw-filter-knopf vw-an" data-tab="anfragen">Anfragen</button>
<button type="button" class="vw-filter-knopf vw-an" data-tab="uebersicht">Übersicht</button>
<button type="button" class="vw-filter-knopf" data-tab="anfragen">Anfragen<span
class="vw-abzeichen" id="vw-anf-zaehler" hidden>0</span></button>
<button type="button" class="vw-filter-knopf" data-tab="projekte">Projekte</button>
<button type="button" class="vw-filter-knopf" data-tab="kunden">Kunden</button>
<button type="button" class="vw-filter-knopf" data-tab="zahlungen">Zahlungen</button>
@@ -648,17 +838,56 @@
</div>
</div>
<div class="vw-zahlen" id="vw-zahlen"></div>
<!-- ============ ÜBERSICHT — das Cockpit ============
<div class="vw-filter" id="vw-filter"></div>
Die Verwaltung öffnete bisher mit der Anfragenliste. Eine
Liste ist eine Ablage: Sie zeigt, WAS es gibt, nicht was zu
TUN ist. Wer hereinkommt, musste durch fünf Reiter klicken,
um sich ein Bild zu machen — und übersah dabei zuverlässig
das eine, das seit Tagen liegt.
<div class="wd-feld" style="margin-bottom:1rem">
Der Aufbau folgt einer Frage in drei Stufen:
1. Was wartet auf MICH? (das ist heute meine Arbeit)
2. Was wartet auf den KUNDEN? (da bin ich raus)
3. Wie steht es ums Geld? (Umsatz, Gebühren, Offenes)
Erst danach Projekte und Verlauf. -->
<div id="vw-uebersicht-bereich">
<div class="vw-laedt" id="vw-ub-laedt">Wird geladen…</div>
<div id="vw-ub-inhalt" hidden>
<h2 class="vw-ub-h">Was auf dich wartet</h2>
<div class="vw-ub-gitter" id="vw-ub-beimir"></div>
<h2 class="vw-ub-h">Was beim Kunden liegt</h2>
<p class="wd-klein wd-leise vw-ub-hinweis">
Getrennt aufgeführt, weil es nicht dasselbe ist: Zwölf offene
Punkte sind entspannt, wenn elf davon beim Kunden liegen — und
ein Problem, wenn alle elf bei dir liegen.
</p>
<div class="vw-ub-gitter" id="vw-ub-beikunde"></div>
<h2 class="vw-ub-h">Geld</h2>
<div class="vw-ub-gitter" id="vw-ub-geld"></div>
<h2 class="vw-ub-h">Laufende Projekte</h2>
<div id="vw-ub-projekte"></div>
<h2 class="vw-ub-h">Zuletzt geschehen</h2>
<div id="vw-ub-verlauf"></div>
</div>
</div>
<div class="vw-zahlen" id="vw-zahlen" hidden></div>
<div class="vw-filter" id="vw-filter" hidden></div>
<div class="wd-feld" style="margin-bottom:1rem" hidden>
<input type="search" id="vw-suche" class="vw-suche" placeholder="Nach Name, E-Mail oder Nummer suchen…"
style="width:100%" aria-label="Anfragen durchsuchen" />
</div>
<div class="wd-meldung" id="vw-meldung" hidden></div>
<div id="vw-liste"><div class="vw-laedt">Wird geladen…</div></div>
<div id="vw-liste" hidden><div class="vw-laedt">Wird geladen…</div></div>
<!-- ============ KUNDEN ============ -->
<div id="vw-kunden-bereich" hidden>
@@ -956,7 +1185,7 @@
tokenSchreiben(token);
document.getElementById("vw-name").textContent = antwort.name || antwort.actor || "angemeldet";
zeigeBereich();
ladeAnfragen();
ladeUebersicht();
/* Der Postfach-Zaehler direkt nach der Anmeldung. Ohne das sieht man
neue Nachrichten erst, wenn man den Reiter zufaellig anklickt --
also womoeglich tagelang nicht. */
@@ -978,6 +1207,225 @@
});
/* ---------------- Liste ---------------- */
/* =================================================================
ÜBERSICHT — das Cockpit
Zwei Entscheidungen tragen diese Ansicht:
1. JEDE KACHEL IST EIN KNOPF. Eine Zahl, die man nur anschauen kann,
ist Dekoration. "3 ungelesene Nachrichten" ist erst dann
nützlich, wenn ein Klick darauf im Postfach landet. Deshalb sind
es <button>-Elemente und keine <div>s — damit sie auch mit der
Tastatur erreichbar sind und ein Screenreader sie als bedienbar
ansagt.
2. DAS ALTER FÄRBT, NICHT DIE MENGE. Vier neue Anfragen sind kein
Problem. Eine Anfrage, die seit sechs Tagen liegt, ist eines.
Deshalb entscheidet das Alter des ÄLTESTEN Vorgangs über die
Warnfarbe — nicht die Anzahl.
================================================================= */
/* Alter in Tagen. Bewusst über Kalendertage statt über 24-Stunden-
Schritte: Wer Montag früh eine Anfrage bekommt, hat sie Dienstag
früh "seit gestern" liegen — auch wenn erst 23 Stunden vergangen
sind. Das entspricht dem, wie ein Kunde die Wartezeit erlebt. */
function tageSeit(iso) {
if (!iso) return null;
var d = new Date(iso);
if (isNaN(d)) return null;
var a = new Date(d.getFullYear(), d.getMonth(), d.getDate());
var h = new Date();
h = new Date(h.getFullYear(), h.getMonth(), h.getDate());
return Math.round((h - a) / 864e5);
}
function altersText(iso) {
var t = tageSeit(iso);
if (t === null) return "";
if (t <= 0) return "seit heute";
if (t === 1) return "seit gestern";
return "seit " + t + " Tagen";
}
/* Ab wann es dringend wird. Drei Werktage sind die Grenze, unterhalb
derer sich niemand vergessen fühlt — darüber schon. */
function dringlichkeit(iso, grenze) {
var t = tageSeit(iso);
if (t === null) return "";
if (t >= (grenze || 3) * 2) return " vw-ub-kachel--rot";
if (t >= (grenze || 3)) return " vw-ub-kachel--warn";
return "";
}
function kachel(o) {
var leer = !o.zahl || o.zahl === "0" || o.zahl === 0;
/* Eine Kachel mit Null ist eine gute Nachricht und darf sich
zurücknehmen — sie soll den Blick nicht von dem wegziehen, was
wirklich offen ist. */
return '<button type="button" class="vw-ub-kachel' +
(leer ? " vw-ub-kachel--leer" : (o.ton || "")) + '"' +
(o.ziel ? ' data-ziel="' + s(o.ziel) + '"' : ' disabled') +
' aria-label="' + s(o.titel + ": " + o.zahl + (o.unten ? ", " + o.unten : "")) + '">' +
'<span class="vw-ub-zahl">' + s(String(o.zahl)) + '</span>' +
'<span class="vw-ub-titel">' + s(o.titel) + '</span>' +
(o.unten ? '<span class="vw-ub-unten">' + s(o.unten) + '</span>' : "") +
'</button>';
}
var uebersichtGeladen = 0;
async function ladeUebersicht() {
var laedt = document.getElementById("vw-ub-laedt");
var inhalt = document.getElementById("vw-ub-inhalt");
/* Beim erneuten Öffnen NICHT wieder auf "Wird geladen…" zurückfallen.
Der alte Stand ist ein paar Sekunden alt und damit brauchbarer als
ein leerer Kasten — er wird still ersetzt, sobald die neuen Zahlen
da sind. */
if (!uebersichtGeladen) { laedt.hidden = false; inhalt.hidden = true; }
var d;
try {
d = await api("/webdesign/admin/uebersicht");
} catch (fehler) {
laedt.hidden = true;
if (!uebersichtGeladen) {
inhalt.hidden = false;
document.getElementById("vw-ub-beimir").innerHTML =
'<p class="wd-meldung wd-meldung--fehler">Die Übersicht konnte nicht geladen werden' +
(fehler && fehler.status ? " (Fehler " + s(String(fehler.status)) + ")" : "") +
'. Die einzelnen Reiter oben funktionieren unabhängig davon.</p>';
}
return;
}
if (!d || !d.ok) return;
/* Eine Antwort, die zwar ok meldet, aber die Teile nicht mitbringt,
darf die Seite nicht sprengen. Vorher lief sie geradewegs in
d.beiMir.anfragen und warf einen Fehler, der die restliche
Verwaltung mitriss -- aufgefallen am 23.08.2026, als eine
Ersatzantwort im Test genau das ausloeste. Ein halber Server ist
ein realistischer Fall (alte Fassung noch nicht neu gestartet),
und dann sollen die uebrigen Reiter weiter benutzbar sein. */
if (!d.beiMir || !d.beiKunde || !d.geld) {
laedt.hidden = true;
inhalt.hidden = false;
document.getElementById("vw-ub-beimir").innerHTML =
'<p class="wd-meldung">Die Übersicht ist gerade nicht verfügbar. ' +
'Die Reiter oben funktionieren unabhängig davon.</p>';
return;
}
uebersichtGeladen = 1;
laedt.hidden = true;
inhalt.hidden = false;
var m = d.beiMir, k = d.beiKunde, g = d.geld;
document.getElementById("vw-ub-beimir").innerHTML =
kachel({ zahl: m.anfragen.n, titel: "offene Anfragen", ziel: "anfragen",
unten: m.anfragen.n ? "älteste " + altersText(m.anfragen.aeltestes) : "alles beantwortet",
ton: dringlichkeit(m.anfragen.aeltestes, 2) }) +
kachel({ zahl: m.nachrichten.n, titel: "ungelesene Nachrichten", ziel: "postfach",
unten: m.nachrichten.n ? "älteste " + altersText(m.nachrichten.aeltestes) : "nichts Neues",
ton: dringlichkeit(m.nachrichten.aeltestes, 2) }) +
kachel({ zahl: m.wuensche.n, titel: "Änderungswünsche zu prüfen", ziel: "projekte",
unten: m.wuensche.n ? "ältester " + altersText(m.wuensche.aeltestes) : "keine offen",
ton: dringlichkeit(m.wuensche.aeltestes, 3) }) +
kachel({ zahl: m.projekte, titel: "Projekte am Zug", ziel: "projekte",
unten: m.projekte ? "du bist dran" : "nichts liegt bei dir" }) +
/* Ein Widerruf hat eine gesetzliche Frist. Er wird deshalb IMMER
rot dargestellt, sobald einer offen ist — unabhängig vom Alter.
Hier auf einen Schwellwert zu warten wäre der falsche Ort für
Zurückhaltung. */
(m.widerrufe.n
? kachel({ zahl: m.widerrufe.n, titel: "Widerrufe unbearbeitet", ziel: "zahlungen",
unten: "gesetzliche Frist läuft", ton: " vw-ub-kachel--rot" })
: "");
document.getElementById("vw-ub-beikunde").innerHTML =
kachel({ zahl: k.projekte, titel: "Projekte warten auf Kunden", ziel: "projekte",
unten: k.projekte ? "du bist hier raus" : "—" }) +
kachel({ zahl: k.aufgaben, titel: "offene Kundenaufgaben", ziel: "projekte",
unten: k.aufgaben ? "z. B. Texte, Bilder, Freigaben" : "nichts ausstehend" }) +
kachel({ zahl: k.zahlungen.n, titel: "unbezahlte Rechnungen", ziel: "zahlungen",
unten: k.zahlungen.n ? k.zahlungenText + " offen · älteste "
+ altersText(k.zahlungen.aeltestes) : "alles beglichen",
ton: dringlichkeit(k.zahlungen.aeltestes, 14) });
document.getElementById("vw-ub-geld").innerHTML =
kachel({ zahl: g.monat.bruttoText, titel: "Umsatz diesen Monat", ziel: "zahlungen",
unten: g.monat.n + (g.monat.n === 1 ? " Zahlung" : " Zahlungen") }) +
/* Der Unterschied zwischen Umsatz und dem, was ankommt, gehört
sichtbar gemacht. Sonst rechnet man am Jahresende mit der
falschen Zahl — PayPal behält seinen Anteil lautlos ein. */
kachel({ zahl: g.gebuehrenText, titel: "davon PayPal-Gebühr", ziel: "zahlungen",
unten: "netto angekommen: " + g.monat.nettoText }) +
kachel({ zahl: k.zahlungenText, titel: "noch ausstehend", ziel: "zahlungen",
unten: k.zahlungen.n ? "aus " + k.zahlungen.n + " Rechnung" +
(k.zahlungen.n === 1 ? "" : "en") : "nichts offen" }) +
kachel({ zahl: g.gesamt.bruttoText, titel: "Umsatz gesamt", ziel: "zahlungen",
unten: g.gesamt.n + " Zahlungen bisher" });
/* ---- Projekte mit Fortschrittsbalken ---- */
var pz = document.getElementById("vw-ub-projekte");
if (!d.projekte.length) {
pz.innerHTML = '<p class="wd-klein wd-leise">Zurzeit läuft kein Projekt.</p>';
} else {
pz.innerHTML = d.projekte.map(function (p) {
var ganz = p.aufg_gesamt || 0;
var teil = p.aufg_fertig || 0;
var pro = ganz ? Math.round((teil / ganz) * 100) : 0;
var beiMir = p.wartet_auf === "dogfather";
return '<button type="button" class="vw-ub-projekt" data-ziel="projekte">' +
'<span class="vw-ub-pkopf">' +
'<span class="vw-ub-pname">' + s(p.titel || "Ohne Titel") + '</span>' +
'<span class="vw-ub-pwer' + (beiMir ? " vw-ub-pwer--ich" : "") + '">' +
(beiMir ? "du bist dran" : "wartet auf Kunden") + '</span>' +
'</span>' +
'<span class="vw-ub-pkunde">' + s(p.kunde_name || "") +
' · ' + s(p.nummer || "") +
(p.richttermin ? ' · Richttermin ' + s(p.richttermin) : "") + '</span>' +
/* Der Balken trägt die Zahl daneben im Text. Eine Farbe allein
ist keine Information — wer sie nicht unterscheiden kann,
sieht sonst gar nichts. */
'<span class="vw-ub-balken" role="img" aria-label="' +
s(teil + " von " + ganz + " Schritten erledigt") + '">' +
'<span class="vw-ub-balken-voll" style="width:' + pro + '%"></span>' +
'</span>' +
'<span class="vw-ub-punten">' + teil + ' von ' + ganz + ' Schritten · ' + pro + ' %</span>' +
'</button>';
}).join("");
}
/* ---- Verlauf ---- */
var vz = document.getElementById("vw-ub-verlauf");
if (!d.verlauf.length) {
vz.innerHTML = '<p class="wd-klein wd-leise">Noch nichts geschehen.</p>';
} else {
vz.innerHTML = '<ul class="vw-ub-verlauf">' + d.verlauf.map(function (v) {
return '<li><span class="vw-ub-vzeit">' +
s(window.WD.datumZeit ? window.WD.datumZeit(v.erstellt_am) : (v.erstellt_am || "")) +
'</span><span class="vw-ub-vwas">' + s(v.aktion || "") + '</span>' +
'<span class="vw-ub-vwer">' + s(v.bereich || "") +
(v.wer ? " · " + s(v.wer) : "") + '</span></li>';
}).join("") + '</ul>';
}
/* Der Zähler am Anfragen-Reiter, damit man auch aus einer anderen
Ansicht heraus sieht, dass etwas hereingekommen ist. */
var az = document.getElementById("vw-anf-zaehler");
if (az) { az.textContent = m.anfragen.n; az.hidden = !m.anfragen.n; }
}
/* Ein Klick auf eine Kachel führt in den passenden Reiter. Über
Ereignis-Delegation, weil die Kacheln bei jedem Laden neu entstehen
— einzeln angehängte Zuhörer wären nach der ersten Aktualisierung
verschwunden. */
document.addEventListener("click", function (e) {
var ziel = e.target.closest ? e.target.closest("[data-ziel]") : null;
if (!ziel) return;
var knopf = document.querySelector('[data-tab="' + ziel.dataset.ziel + '"]');
if (knopf) knopf.click();
});
async function ladeAnfragen() {
var liste = document.getElementById("vw-liste");
liste.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
@@ -1522,7 +1970,25 @@
/* =================================================================
KUNDEN
================================================================= */
var aktiverTab = "anfragen";
/* Reiter -> Titel und zugehoeriger Kasten.
Steht bewusst AUSSERHALB des Klick-Zuhoerers. Solange die Zuordnung
nur beim Klicken gelesen wurde, konnte die Ueberschrift beim Laden
gar nicht stimmen: Am 23.08.2026 zeigte die frisch geoeffnete
Verwaltung das Cockpit, waehrend darueber noch "Projektanfragen"
stand -- der Titel wurde erst beim ersten Klick richtig. Jetzt gibt
es eine Quelle, aus der sich beides speist. */
var ANSICHTEN = {
uebersicht:{ titel: "Übersicht", kasten: "vw-uebersicht-bereich" },
anfragen: { titel: "Projektanfragen", kasten: null },
projekte: { titel: "Projekte", kasten: "vw-projekte-bereich" },
kunden: { titel: "Kunden", kasten: "vw-kunden-bereich" },
zahlungen:{ titel: "Zahlungen", kasten: "vw-zahlungen-bereich" },
postfach: { titel: "Postfach", kasten: "vw-postfach-bereich" },
};
var aktiverTab = "uebersicht";
document.getElementById("vw-titel").textContent = ANSICHTEN[aktiverTab].titel;
document.querySelectorAll("[data-tab]").forEach(function (b) {
b.addEventListener("click", function () {
@@ -1537,14 +2003,6 @@
uebereinander. Deshalb jetzt eine Zuordnung: Reiter -> Titel und
zugehoeriger Kasten. Alles andere wird ausgeblendet, ohne dass
man es einzeln aufzaehlen muss. */
var ANSICHTEN = {
anfragen: { titel: "Projektanfragen", kasten: null },
projekte: { titel: "Projekte", kasten: "vw-projekte-bereich" },
kunden: { titel: "Kunden", kasten: "vw-kunden-bereich" },
zahlungen:{ titel: "Zahlungen", kasten: "vw-zahlungen-bereich" },
postfach: { titel: "Postfach", kasten: "vw-postfach-bereich" },
};
var istAnfragen = aktiverTab === "anfragen";
document.getElementById("vw-titel").textContent = ANSICHTEN[aktiverTab].titel;
@@ -1560,6 +2018,8 @@
if (id) document.getElementById(id).hidden = name !== aktiverTab;
});
if (aktiverTab === "uebersicht") ladeUebersicht();
if (aktiverTab === "anfragen") ladeAnfragen();
if (aktiverTab === "kunden") ladeKunden();
if (aktiverTab === "projekte") ladeProjekte();
if (aktiverTab === "zahlungen") ladeZahlungen();
@@ -2788,7 +3248,7 @@
funktionierende Sitzung durch einen untauglichen Ausweis zu
ersetzen. */
tokenQuelle = "code";
zeigeBereich(); ladeAnfragen(); zaehlerHolen();
zeigeBereich(); ladeUebersicht(); zaehlerHolen();
return;
}
@@ -2800,7 +3260,7 @@
document.getElementById("vw-name").textContent =
(document.cookie.match(/dogiwd_rolle=([^;]+)/) || [])[1] === "vanvan" ? "VanVan" : "Dogfather";
zeigeBereich();
ladeAnfragen();
ladeUebersicht();
/* Auch auf diesem Weg. Der Ausweis ist der ÜBLICHE Einstieg (man
kommt von der Zugangswand und ist sofort drin) -- ohne diese
Zeile bliebe der Zähler ausgerechnet im Normalfall leer. */