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
+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 });