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:
@@ -20,3 +20,4 @@ server/node_modules/
|
|||||||
# kollidieren und den Deploy blockieren -- genau das ist gerade passiert.
|
# kollidieren und den Deploy blockieren -- genau das ist gerade passiert.
|
||||||
server/tmp-lokal.mjs
|
server/tmp-lokal.mjs
|
||||||
server/package-lock.json
|
server/package-lock.json
|
||||||
|
bild-*.png
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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(),
|
anzahl: 3, ungelesen: 1, letzte: new Date(Date.now() - 1800000).toISOString(),
|
||||||
letzter_text: "Nicht vergessen: Rechnung erst nach Abnahme.", letzter_autor: "dogfather" }] } });
|
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/anfragen")) return route.fulfill({ json: { ok: true, anfragen: [] } });
|
||||||
if (u.includes("/admin/kunden")) return route.fulfill({ json: { ok: true, kunden: [] } });
|
if (u.includes("/admin/kunden")) return route.fulfill({ json: { ok: true, kunden: [] } });
|
||||||
return route.fulfill({ json: { ok: true } });
|
return route.fulfill({ json: { ok: true } });
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
zahlungAnsehen, zahlungStarten, zahlungAbschliessen,
|
zahlungAnsehen, zahlungStarten, zahlungAbschliessen,
|
||||||
paypalWebhook, zahlungAnlegen, zahlungenListe,
|
paypalWebhook, zahlungAnlegen, zahlungenListe,
|
||||||
einstellungenLesen, einstellungenSchreiben, einstellungenPruefen,
|
einstellungenLesen, einstellungenSchreiben, einstellungenPruefen, uebersicht,
|
||||||
} from "./routes/webdesign-zahlungen.js";
|
} from "./routes/webdesign-zahlungen.js";
|
||||||
/* PayPal-Zugangsdaten koennen verschluesselt in der Datenbank liegen,
|
/* PayPal-Zugangsdaten koennen verschluesselt in der Datenbank liegen,
|
||||||
damit Filipe sie in seiner Verwaltung eintragen kann statt per SSH.
|
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.post("/webdesign/portal/zahlung/:id/abschluss", wrap(zahlungAbschliessen));
|
||||||
app.get("/webdesign/admin/zahlungen", wrap(zahlungenListe));
|
app.get("/webdesign/admin/zahlungen", wrap(zahlungenListe));
|
||||||
app.post("/webdesign/admin/zahlungen", wrap(zahlungAnlegen));
|
app.post("/webdesign/admin/zahlungen", wrap(zahlungAnlegen));
|
||||||
|
app.get("/webdesign/admin/uebersicht", wrap(uebersicht));
|
||||||
app.get("/webdesign/admin/einstellungen", wrap(einstellungenLesen));
|
app.get("/webdesign/admin/einstellungen", wrap(einstellungenLesen));
|
||||||
app.post("/webdesign/admin/einstellungen", wrap(einstellungenSchreiben));
|
app.post("/webdesign/admin/einstellungen", wrap(einstellungenSchreiben));
|
||||||
app.post("/webdesign/admin/einstellungen/pruefen", wrap(einstellungenPruefen));
|
app.post("/webdesign/admin/einstellungen/pruefen", wrap(einstellungenPruefen));
|
||||||
|
|||||||
@@ -577,3 +577,150 @@ export async function einstellungenPruefen(req, res) {
|
|||||||
}, 400);
|
}, 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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -34,7 +34,7 @@
|
|||||||
Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den
|
Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den
|
||||||
Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten
|
Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten
|
||||||
Stand fest. */
|
Stand fest. */
|
||||||
const CACHE_NAME = "dogfather-webdesign-v20";
|
const CACHE_NAME = "dogfather-webdesign-v21";
|
||||||
|
|
||||||
/* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE
|
/* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE
|
||||||
HTML-Datei in dieser Liste. */
|
HTML-Datei in dieser Liste. */
|
||||||
|
|||||||
+482
-22
@@ -28,14 +28,202 @@
|
|||||||
.vw-login { max-width: 420px; margin: clamp(2rem,8vh,5rem) auto; }
|
.vw-login { max-width: 420px; margin: clamp(2rem,8vh,5rem) auto; }
|
||||||
|
|
||||||
/* ---------- Kopfleiste ---------- */
|
/* ---------- 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 {
|
.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;
|
margin-bottom: 1.2rem;
|
||||||
}
|
}
|
||||||
.vw-leiste h1 { margin: 0; font-size: clamp(1.4rem, 3.4vw, 2rem); }
|
.vw-leiste h1 {
|
||||||
.vw-wer { margin-left: auto; display: flex; align-items: center; gap: .6rem; font-size: .9rem; color: var(--wd-text-soft); }
|
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 ----------
|
/* ---------- Kennzahlen, gehobene Fassung ----------
|
||||||
Die erste Fassung war eine Reihe stiller Zahlen. Zahlen, die man
|
Die erste Fassung war eine Reihe stiller Zahlen. Zahlen, die man
|
||||||
nur anschauen kann, sind Dekoration. Diese hier sind Werkzeuge:
|
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-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-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 {
|
.vw-link-anzeige {
|
||||||
padding: 1rem; border-radius: 14px;
|
padding: 1rem; border-radius: 14px;
|
||||||
background: rgb(var(--wd-gold-rgb) / .08); border: 1px solid rgb(var(--wd-gold-rgb) / .35);
|
background: rgb(var(--wd-gold-rgb) / .08); border: 1px solid rgb(var(--wd-gold-rgb) / .35);
|
||||||
@@ -633,9 +821,11 @@
|
|||||||
<!-- ============ ARBEITSBEREICH ============ -->
|
<!-- ============ ARBEITSBEREICH ============ -->
|
||||||
<div id="vw-bereich" hidden>
|
<div id="vw-bereich" hidden>
|
||||||
<div class="vw-leiste">
|
<div class="vw-leiste">
|
||||||
<h1 id="vw-titel">Projektanfragen</h1>
|
<h1 id="vw-titel">Übersicht</h1>
|
||||||
<div class="vw-tabs">
|
<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="projekte">Projekte</button>
|
||||||
<button type="button" class="vw-filter-knopf" data-tab="kunden">Kunden</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>
|
<button type="button" class="vw-filter-knopf" data-tab="zahlungen">Zahlungen</button>
|
||||||
@@ -648,17 +838,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</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…"
|
<input type="search" id="vw-suche" class="vw-suche" placeholder="Nach Name, E-Mail oder Nummer suchen…"
|
||||||
style="width:100%" aria-label="Anfragen durchsuchen" />
|
style="width:100%" aria-label="Anfragen durchsuchen" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="wd-meldung" id="vw-meldung" hidden></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 ============ -->
|
<!-- ============ KUNDEN ============ -->
|
||||||
<div id="vw-kunden-bereich" hidden>
|
<div id="vw-kunden-bereich" hidden>
|
||||||
@@ -956,7 +1185,7 @@
|
|||||||
tokenSchreiben(token);
|
tokenSchreiben(token);
|
||||||
document.getElementById("vw-name").textContent = antwort.name || antwort.actor || "angemeldet";
|
document.getElementById("vw-name").textContent = antwort.name || antwort.actor || "angemeldet";
|
||||||
zeigeBereich();
|
zeigeBereich();
|
||||||
ladeAnfragen();
|
ladeUebersicht();
|
||||||
/* Der Postfach-Zaehler direkt nach der Anmeldung. Ohne das sieht man
|
/* Der Postfach-Zaehler direkt nach der Anmeldung. Ohne das sieht man
|
||||||
neue Nachrichten erst, wenn man den Reiter zufaellig anklickt --
|
neue Nachrichten erst, wenn man den Reiter zufaellig anklickt --
|
||||||
also womoeglich tagelang nicht. */
|
also womoeglich tagelang nicht. */
|
||||||
@@ -978,6 +1207,225 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
/* ---------------- Liste ---------------- */
|
/* ---------------- 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() {
|
async function ladeAnfragen() {
|
||||||
var liste = document.getElementById("vw-liste");
|
var liste = document.getElementById("vw-liste");
|
||||||
liste.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
|
liste.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
|
||||||
@@ -1522,7 +1970,25 @@
|
|||||||
/* =================================================================
|
/* =================================================================
|
||||||
KUNDEN
|
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) {
|
document.querySelectorAll("[data-tab]").forEach(function (b) {
|
||||||
b.addEventListener("click", function () {
|
b.addEventListener("click", function () {
|
||||||
@@ -1537,14 +2003,6 @@
|
|||||||
uebereinander. Deshalb jetzt eine Zuordnung: Reiter -> Titel und
|
uebereinander. Deshalb jetzt eine Zuordnung: Reiter -> Titel und
|
||||||
zugehoeriger Kasten. Alles andere wird ausgeblendet, ohne dass
|
zugehoeriger Kasten. Alles andere wird ausgeblendet, ohne dass
|
||||||
man es einzeln aufzaehlen muss. */
|
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";
|
var istAnfragen = aktiverTab === "anfragen";
|
||||||
document.getElementById("vw-titel").textContent = ANSICHTEN[aktiverTab].titel;
|
document.getElementById("vw-titel").textContent = ANSICHTEN[aktiverTab].titel;
|
||||||
|
|
||||||
@@ -1560,6 +2018,8 @@
|
|||||||
if (id) document.getElementById(id).hidden = name !== aktiverTab;
|
if (id) document.getElementById(id).hidden = name !== aktiverTab;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (aktiverTab === "uebersicht") ladeUebersicht();
|
||||||
|
if (aktiverTab === "anfragen") ladeAnfragen();
|
||||||
if (aktiverTab === "kunden") ladeKunden();
|
if (aktiverTab === "kunden") ladeKunden();
|
||||||
if (aktiverTab === "projekte") ladeProjekte();
|
if (aktiverTab === "projekte") ladeProjekte();
|
||||||
if (aktiverTab === "zahlungen") ladeZahlungen();
|
if (aktiverTab === "zahlungen") ladeZahlungen();
|
||||||
@@ -2788,7 +3248,7 @@
|
|||||||
funktionierende Sitzung durch einen untauglichen Ausweis zu
|
funktionierende Sitzung durch einen untauglichen Ausweis zu
|
||||||
ersetzen. */
|
ersetzen. */
|
||||||
tokenQuelle = "code";
|
tokenQuelle = "code";
|
||||||
zeigeBereich(); ladeAnfragen(); zaehlerHolen();
|
zeigeBereich(); ladeUebersicht(); zaehlerHolen();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2800,7 +3260,7 @@
|
|||||||
document.getElementById("vw-name").textContent =
|
document.getElementById("vw-name").textContent =
|
||||||
(document.cookie.match(/dogiwd_rolle=([^;]+)/) || [])[1] === "vanvan" ? "VanVan" : "Dogfather";
|
(document.cookie.match(/dogiwd_rolle=([^;]+)/) || [])[1] === "vanvan" ? "VanVan" : "Dogfather";
|
||||||
zeigeBereich();
|
zeigeBereich();
|
||||||
ladeAnfragen();
|
ladeUebersicht();
|
||||||
/* Auch auf diesem Weg. Der Ausweis ist der ÜBLICHE Einstieg (man
|
/* Auch auf diesem Weg. Der Ausweis ist der ÜBLICHE Einstieg (man
|
||||||
kommt von der Zugangswand und ist sofort drin) -- ohne diese
|
kommt von der Zugangswand und ist sofort drin) -- ohne diese
|
||||||
Zeile bliebe der Zähler ausgerechnet im Normalfall leer. */
|
Zeile bliebe der Zähler ausgerechnet im Normalfall leer. */
|
||||||
|
|||||||
Reference in New Issue
Block a user