diff --git a/pruef-verwaltung.mjs b/pruef-verwaltung.mjs new file mode 100644 index 00000000..2455054f --- /dev/null +++ b/pruef-verwaltung.mjs @@ -0,0 +1,256 @@ +/* Prüft die neuen Bereiche der Verwaltung: Projekte mit Aufgabenliste + und das Postfach. Wieder mit abgefangenen Netzanfragen, aber mit + Daten, die den heiklen Faellen entsprechen: + + - ein Punkt, der auf den KUNDEN wartet (muss auffallen) + - ein Punkt "nicht enthalten" (muss als solcher erkennbar sein) + - eine INTERNE Notiz im Postfach (darf nicht wie eine gesendete + Nachricht aussehen) +*/ +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(4325, r)); + +const PROJEKT = { + id: "p1", nummer: "P-0001", titel: "Website für Musterbau", paket: "website", + status: "design", kunde_id: "k1", kunde_name: "Anna Muster", kunde_firma: "Musterbau", + preis_cent: 180000, waehrung: "EUR", richttermin: null, archiviert: 0, + anzahlung_bezahlt: 1, rest_bezahlt: 0, erstellt_am: new Date().toISOString(), + aufg_gesamt: 6, aufg_erledigt: 2, aufg_kunde: 2, ungelesen: 1, +}; + +const AUFGABEN = [ + { id: "a1", titel: "Briefing-Gespräch führen", beschreibung: "Ziel und Umfang klären.", + kategorie: "start", status: "erledigt", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null, reihenfolge: 10 }, + { id: "a2", titel: "Texte und Bilder zusammenstellen", beschreibung: "Lieber früh als vollständig.", + kategorie: "start", status: "offen", wer_dran: "kunde", nicht_enthalten: 0, hinweis: null, reihenfolge: 20 }, + { id: "a3", titel: "Entwurf der Startseite", beschreibung: null, + kategorie: "gestaltung", status: "erledigt", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null, reihenfolge: 110 }, + { id: "a4", titel: "Entwurf ansehen und Rückmeldung geben", beschreibung: null, + kategorie: "gestaltung", status: "offen", wer_dran: "kunde", nicht_enthalten: 0, hinweis: null, reihenfolge: 130 }, + { id: "a5", titel: "Alle Unterseiten bauen", beschreibung: null, + kategorie: "umsetzung", status: "in_arbeit", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null, reihenfolge: 210 }, + { id: "a6", titel: "Mehrsprachigkeit", beschreibung: null, + kategorie: "umsetzung", status: "offen", wer_dran: "dogfather", nicht_enthalten: 1, + hinweis: "Nicht im Paket enthalten — auf Wunsch als Zusatz.", reihenfolge: 260 }, + { id: "a7", titel: "Seite live schalten", beschreibung: null, + kategorie: "abschluss", status: "offen", wer_dran: "dogfather", nicht_enthalten: 0, hinweis: null, reihenfolge: 860 }, +]; + +const FORTSCHRITT = { + gesamt: 6, erledigt: 2, inArbeit: 1, beimKunden: 2, prozent: 33, + jeKategorie: { + start: { gesamt: 2, erledigt: 1, inArbeit: 0 }, + gestaltung: { gesamt: 2, erledigt: 1, inArbeit: 0 }, + umsetzung: { gesamt: 1, erledigt: 0, inArbeit: 1 }, + abschluss: { gesamt: 1, erledigt: 0, inArbeit: 0 }, + }, +}; + +const VERLAUF = [ + { id: "n1", autor: "kunde", text: "Hallo! Können wir das Logo noch etwas grösser machen?", + intern: 0, projekt_titel: null, erstellt_am: new Date(Date.now() - 7200000).toISOString() }, + { id: "n2", autor: "dogfather", text: "Klar, mache ich heute noch.", + intern: 0, projekt_titel: "Website für Musterbau", erstellt_am: new Date(Date.now() - 3600000).toISOString() }, + { id: "n3", autor: "dogfather", text: "Nicht vergessen: Rechnung erst nach Abnahme.", + intern: 1, projekt_titel: null, erstellt_am: new Date(Date.now() - 1800000).toISOString() }, +]; + +const ergebnisse = []; +function pruefe(name, ok, zusatz) { + ergebnisse.push({ name, ok }); + console.log((ok ? " ok " : " FEHL ") + name + (zusatz ? " -> " + zusatz : "")); +} + +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)); + + // NUR die API abfangen. Ein Muster, das den ganzen Ordner /webdesign + // umfasst, trifft auch die Seite SELBST -- dann bekommt der Browser + // statt der HTML-Datei ein JSON serviert. Genau das ist beim ersten + // Lauf passiert: Die Seite blieb leer, und der Test meldete + // "Abzeichen fehlt" statt "Seite gar nicht geladen". + // (Und der Hinweis steht in Zeilenkommentaren, weil das Glob-Muster + // ein Sternchen-Schrägstrich enthält und einen Blockkommentar + // vorzeitig beenden würde -- auch das ist hier schon passiert.) + await seite.route("**/webdesign/admin/**", (route) => { + const u = route.request().url(); + if (u.includes("/admin/projekte/p1/aufgaben")) { + return route.fulfill({ json: { ok: true, projekt: PROJEKT, aufgaben: AUFGABEN, fortschritt: FORTSCHRITT } }); + } + if (u.includes("/admin/projekte")) return route.fulfill({ json: { ok: true, projekte: [PROJEKT] } }); + if (u.includes("/admin/postfach-zaehler")) return route.fulfill({ json: { ok: true, ungelesen: 1 } }); + if (u.includes("/admin/postfach/k1")) { + return route.fulfill({ json: { ok: true, + kunde: { id: "k1", name: "Anna Muster", email: "anna@example.com", sprache: "de" }, + nachrichten: VERLAUF, projekte: [{ id: "p1", titel: "Website für Musterbau" }] } }); + } + if (u.includes("/admin/postfach")) { + return route.fulfill({ json: { ok: true, ungelesen: 1, verlaeufe: [{ + kunde_id: "k1", name: "Anna Muster", firma: "Musterbau", email: "anna@example.com", + anzahl: 3, ungelesen: 1, letzte: new Date(Date.now() - 1800000).toISOString(), + letzter_text: "Nicht vergessen: Rechnung erst nach Abnahme.", letzter_autor: "dogfather" }] } }); + } + 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 } }); + }); + 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:4325/webdesign/verwaltung.html"); + await seite.waitForTimeout(900); + console.log("\n=== " + wo + " ==="); + + // Abzeichen am Reiter + const abz = await seite.locator("#vw-pf-zaehler").innerText().catch(() => ""); + const abzSichtbar = await seite.locator("#vw-pf-zaehler").isVisible().catch(() => false); + pruefe(wo + ": Postfach-Abzeichen zeigt die ungelesene Nachricht", abzSichtbar && abz === "1", abz); + + // ---- Projekte ---- + await seite.click('[data-tab="projekte"]'); + await seite.waitForTimeout(500); + pruefe(wo + ": Projektliste zeigt das Projekt", await seite.locator("[data-projekt]").count() === 1); + pruefe(wo + ": „wartet auf Kunde“ ist sichtbar", await seite.locator(".vw-wartet").count() === 1); + + await seite.click("[data-projekt]"); + await seite.waitForTimeout(600); + + const m = await seite.evaluate(() => { + const gruppen = Array.from(document.querySelectorAll(".vw-aufg-gruppe")).map((g) => g.dataset.k); + const farben = Array.from(document.querySelectorAll(".vw-aufg-gruppe")).map((g) => + getComputedStyle(g.querySelector(".vw-aufg-kopf")).color); + const balken = Array.from(document.querySelectorAll(".vw-fs-teil > i")).map((i) => + getComputedStyle(i).backgroundColor); + const zeile = document.querySelector('[data-aufgabe="a2"]'); + const normal = document.querySelector('[data-aufgabe="a5"]'); + return { + gruppen, farben, balkenFarben: [...new Set(balken)].length, + kundenZeileAnders: zeile && normal + ? getComputedStyle(zeile).backgroundColor !== getComputedStyle(normal).backgroundColor : false, + nichtEnthalten: document.querySelectorAll(".vw-aufg-nicht").length, + haken: document.querySelectorAll(".vw-aufg-haken").length, + erledigtDurchgestrichen: getComputedStyle( + document.querySelector('[data-aufgabe="a1"] .vw-aufg-titel')).textDecorationLine, + blickFelder: document.querySelectorAll(".vw-blick-feld").length, + + /* Die REIHENFOLGE im Bild, nicht im Quelltext. + + Diese Pruefung fehlte im ersten Anlauf, und deshalb meldete der + Test 37 von 37 gruen, waehrend jede Zeile sichtbar falsch herum + gebaut war: Kaestchen, dann die kleinen Knoepfe, und der Titel + ganz rechts. Alle inhaltlichen Pruefungen (Farben, Anzahl, + Durchgestrichenes) trafen zu -- keine einzige schaute darauf, WO + die Dinge liegen. Aufgefallen ist es erst am Screenshot. + + Lehre: Wer eine Anordnung baut, muss die Anordnung pruefen, nicht + nur ihre Bestandteile. */ + anordnung: (() => { + const z = document.querySelector('[data-aufgabe="a2"]'); + if (!z) return null; + const k = z.querySelector(".vw-aufg-haken").getBoundingClientRect(); + const t = z.querySelector(".vw-aufg-titel").getBoundingClientRect(); + const r = z.querySelector(".vw-aufg-rechts").getBoundingClientRect(); + const b = z.querySelector(".vw-aufg-text"); + return { + reihenfolgeStimmt: k.right <= t.left + 1 && t.right <= r.left + 1, + textUnterTitel: b ? b.getBoundingClientRect().top >= t.bottom - 2 : true, + textNichtUnterKaestchen: b ? b.getBoundingClientRect().left >= k.right - 1 : true, + }; + })(), + }; + }); + + pruefe(wo + ": alle vier Abschnitte erscheinen", m.gruppen.join(",") === "start,gestaltung,umsetzung,abschluss", m.gruppen.join(",")); + pruefe(wo + ": jeder Abschnitt hat eine EIGENE Farbe", new Set(m.farben).size === 4, m.farben.join(" | ")); + pruefe(wo + ": Punkt für den Kunden hebt sich ab", m.kundenZeileAnders); + pruefe(wo + ": „nicht enthalten“ ist gekennzeichnet", m.nichtEnthalten === 1); + pruefe(wo + ": jeder Punkt hat ein Kästchen zum Abhaken", m.haken === 7, "n=" + m.haken); + pruefe(wo + ": Erledigtes ist durchgestrichen", m.erledigtDurchgestrichen.includes("line-through"), m.erledigtDurchgestrichen); + pruefe(wo + ": Zusammenfassung mit vier Zahlen", m.blickFelder === 4); + pruefe(wo + ": Kästchen — Titel — Knöpfe stehen in dieser Reihenfolge", m.anordnung?.reihenfolgeStimmt); + pruefe(wo + ": die Erklärung steht UNTER dem Titel", m.anordnung?.textUnterTitel); + pruefe(wo + ": die Erklärung ist am Titel ausgerichtet, nicht am Rand", m.anordnung?.textNichtUnterKaestchen); + + // Touch-Ziele auf dem Handy + if (breite < 900) { + const zuKlein = await seite.evaluate(() => + Array.from(document.querySelectorAll(".vw-aufg-haken, .vw-mini")) + .filter((b) => { const r = b.getBoundingClientRect(); return r.width < 24 || r.height < 24; }).length); + pruefe(wo + ": alle Knöpfe sind gross genug zum Antippen", zuKlein === 0, "zu klein: " + zuKlein); + } + + await seite.screenshot({ path: `pruef-aufgaben-${wo.toLowerCase()}.png` }); + await seite.keyboard.press("Escape"); + await seite.waitForTimeout(300); + + // ---- Postfach ---- + await seite.click('[data-tab="postfach"]'); + await seite.waitForTimeout(500); + pruefe(wo + ": Postfach zeigt den Verlauf", await seite.locator("[data-verlauf]").count() === 1); + + await seite.click("[data-verlauf]"); + await seite.waitForTimeout(600); + + const pf = await seite.evaluate(() => { + const intern = document.querySelector(".vw-blase--intern"); + const wir = document.querySelector(".vw-blase--wir"); + const kunde = document.querySelector(".vw-blase--kunde"); + const lauf = document.getElementById("vw-blasen"); + return { + anzahl: document.querySelectorAll(".vw-blase").length, + internDa: !!intern, + internAnders: intern && wir + ? getComputedStyle(intern).borderStyle !== getComputedStyle(wir).borderStyle : false, + kundeLinks: kunde && wir + ? kunde.getBoundingClientRect().left < wir.getBoundingClientRect().left : false, + amEnde: lauf ? lauf.scrollTop + lauf.clientHeight >= lauf.scrollHeight - 5 : false, + schalterDa: !!document.getElementById("vw-pf-intern"), + }; + }); + pruefe(wo + ": alle drei Nachrichten erscheinen", pf.anzahl === 3, "n=" + pf.anzahl); + pruefe(wo + ": die interne Notiz ist da", pf.internDa); + pruefe(wo + ": interne Notiz sieht ANDERS aus als eine gesendete", pf.internAnders); + pruefe(wo + ": Kunde links, eigene Nachrichten rechts", pf.kundeLinks); + pruefe(wo + ": Verlauf springt ans Ende", pf.amEnde); + + // Der Schalter muss das Schreibfeld sichtbar umfaerben + const vorher = await seite.evaluate(() => getComputedStyle(document.getElementById("vw-schreibfeld")).borderColor); + await seite.check("#vw-pf-intern"); + await seite.waitForTimeout(250); + const nachher = await seite.evaluate(() => getComputedStyle(document.getElementById("vw-schreibfeld")).borderColor); + pruefe(wo + ": „interne Notiz“ färbt das Schreibfeld sichtbar um", vorher !== nachher, vorher + " -> " + nachher); + + pruefe(wo + ": keine Fehler im Protokoll", fehlerImProtokoll.length === 0, fehlerImProtokoll.join(" | ")); + + await seite.screenshot({ path: `pruef-postfach-${wo.toLowerCase()}.png` }); + await seite.close(); +} + +await browser.close(); +srv.close(); +const fehler = ergebnisse.filter((e) => !e.ok).length; +console.log(`\n===== ${ergebnisse.length - fehler} bestanden, ${fehler} fehlgeschlagen =====`); +process.exitCode = fehler ? 1 : 0; diff --git a/server-internal/index.js b/server-internal/index.js index c746abc3..5988e511 100644 --- a/server-internal/index.js +++ b/server-internal/index.js @@ -99,7 +99,7 @@ import { Kundenzugaenge -- es gibt bewusst keine Selbstregistrierung. */ import { kundeAnlegen, kundeEinladungNeu, kundenListe, kundeSchalten, - projektAnlegen, projektAendern, aenderungBeziffern, projektNachricht, + projektAnlegen, projektAendern, aenderungBeziffern, projektNachricht, projekteListe, } from "./routes/webdesign-kunden.js"; /* Aufgabenliste je Projekt ("was ist schon gemacht von dem, was im Plan war") und allgemeines Postfach fuer Nachrichten OHNE Projektbezug. */ @@ -279,6 +279,7 @@ app.get("/webdesign/admin/kunden", wrap(kundenListe)); app.post("/webdesign/admin/kunden", wrap(kundeAnlegen)); app.post("/webdesign/admin/kunden/:id/einladung", wrap(kundeEinladungNeu)); app.post("/webdesign/admin/kunden/:id/zugang", wrap(kundeSchalten)); +app.get("/webdesign/admin/projekte", wrap(projekteListe)); app.post("/webdesign/admin/projekte", wrap(projektAnlegen)); app.post("/webdesign/admin/projekte/:id", wrap(projektAendern)); app.post("/webdesign/admin/aenderungen/:id/beziffern", wrap(aenderungBeziffern)); diff --git a/server-internal/routes/webdesign-kunden.js b/server-internal/routes/webdesign-kunden.js index a5d153af..5afc5a9b 100644 --- a/server-internal/routes/webdesign-kunden.js +++ b/server-internal/routes/webdesign-kunden.js @@ -166,6 +166,55 @@ export async function kundenListe(req, res) { return json(res, { ok: true, kunden: zeilen }); } +/* --------------------------------------------------------------------- + PROJEKTE — flache Liste über alle Kunden + + Bisher gab es nur die Anzahl je Kunde. Zum Abhaken von Aufgaben muss + man aber ein einzelnes Projekt öffnen können, ohne vorher zu wissen, + wem es gehört — man denkt in Projekten, nicht in Kunden. + + Der Fortschritt wird gleich mitgerechnet. Er einzeln je Projekt + nachzufragen hiesse bei zwanzig Projekten einundzwanzig Anfragen, + und die Liste würde sichtbar stockend aufbauen. + --------------------------------------------------------------------- */ +export async function projekteListe(req, res) { + const p = await darf(req, "WD_PROJEKTE_MANAGE"); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const zeigeArchiv = req.query?.archiv === "1"; + + const zeilen = db + .prepare( + `SELECT p.id, p.nummer, p.titel, p.paket, p.status, p.kunde_id, + p.preis_cent, p.waehrung, p.richttermin, p.archiviert, + p.anzahlung_bezahlt, p.rest_bezahlt, p.erstellt_am, + k.name AS kunde_name, k.firma AS kunde_firma, + + /* Aufgabenzahlen direkt mitgeliefert. "entfaellt" zaehlt + nicht mit -- ein weggefallener Punkt darf den Fortschritt + weder bremsen noch beschleunigen. */ + (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_erledigt, + /* Die Zahl, die zuerst interessiert: worauf warte ICH gerade + vom Kunden? Steht sie über 0, ist Nachfassen dran. */ + (SELECT COUNT(*) FROM wd_aufgaben a + WHERE a.projekt_id = p.id AND a.wer_dran = 'kunde' + AND a.status <> 'erledigt' AND a.status <> 'entfaellt') AS aufg_kunde, + (SELECT COUNT(*) FROM wd_postfach n + WHERE n.projekt_id = p.id AND n.gelesen_admin = 0 + AND n.autor = 'kunde') AS ungelesen + FROM wd_projekte p + JOIN wd_kunden k ON k.id = p.kunde_id + WHERE p.archiviert = ? + ORDER BY p.erstellt_am DESC LIMIT 300` + ) + .all(zeigeArchiv ? 1 : 0); + + return json(res, { ok: true, projekte: zeilen }); +} + /* --------------------------------------------------------------------- FREISCHALTEN / SPERREN --------------------------------------------------------------------- */ diff --git a/webdesign/ablauf.html b/webdesign/ablauf.html index 868b2b4b..b06092b6 100644 --- a/webdesign/ablauf.html +++ b/webdesign/ablauf.html @@ -13,8 +13,8 @@ - - + + @@ -437,7 +636,10 @@

Projektanfragen

+ +
— @@ -512,6 +714,33 @@

Bestehende Zugänge

Wird geladen…
+ + + + + + @@ -524,7 +753,7 @@
- + - + +