diff --git a/pruef-abbruch.mjs b/pruef-abbruch.mjs new file mode 100644 index 00000000..01d5c3b4 --- /dev/null +++ b/pruef-abbruch.mjs @@ -0,0 +1,204 @@ +/* ===================================================================== + pruef-abbruch.mjs — Auftrag abbrechen, und meins gegen seins + + ZWEI DINGE, DIE MAN NUR MESSEN KANN + + 1. Der Abbruch schickt Geld-Angaben an den Server. Ein Dialog, der + hübsch aussieht und den falschen Betrag sendet, besteht jede + Sichtprüfung — geprüft wird deshalb, WAS hinausgeht. + + 2. Die Farbunterscheidung zwischen "meins" und "beim Kunden" wird an + den tatsächlich berechneten Farben gemessen. Eine CSS-Regel, die + im Quelltext steht, kann von einer anderen überstimmt werden — + dreimal in diesem Projekt schon passiert. + ===================================================================== */ +import { chromium, devices } 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(4359, r)); + +const ergebnisse = []; +const pruefe = (n, ok, z) => { + ergebnisse.push({ n, ok }); + console.log((ok ? " ok " : " FEHL ") + n + (z !== undefined ? " -> " + z : "")); +}; + +const PROJEKT = { + id: "p1", nummer: "P-2608-0001", titel: "Website für Musterbau", paket: "website", + status: "design", wartet_auf: "dogfather", naechster_schritt: "Entwurf", + preis_cent: 200000, anzahlung_cent: 60000, anzahlung_bezahlt: 1, waehrung: "EUR", + kunde_id: "k1", kunde_name: "Anna Muster", termin_am: "2026-10-15", +}; +const UEBERSICHT = { + ok: true, + beiMir: { anfragen: { n: 3, aeltestes: new Date().toISOString() }, + nachrichten: { n: 0 }, wuensche: { n: 0 }, widerrufe: { n: 0 }, projekte: 2 }, + beiKunde: { projekte: 1, aufgaben: 4, + zahlungen: { n: 2, summe: 120000, aeltestes: new Date().toISOString() }, + zahlungenText: "1.200,00 €" }, + geld: { monat: { n: 3, brutto: 250000, netto: 241000, bruttoText: "2.500,00 €", nettoText: "2.410,00 €" }, + gesamt: { n: 11, bruttoText: "9.800,00 €" }, gebuehrenText: "90,00 €" }, + projekte: [], verlauf: [], bestand: {}, meldungen: [], meldungenOffen: 0, wartetAufAnzahlung: [], +}; + +for (const [wo, angaben] of [ + ["Computer", { viewport: { width: 1440, height: 950 } }], + ["Handy", { ...devices["iPhone 14 Pro"] }], +]) { + const browser = await chromium.launch(); + const kontext = await browser.newContext(angaben); + const seite = await kontext.newPage(); + const protokoll = []; + seite.on("pageerror", (e) => protokoll.push(e.message)); + let gesendet = null, vorschauGeholt = 0; + + seite.on("dialog", (d) => d.accept()); + + await seite.route("**/webdesign/admin/**", (route) => { + const u = new URL(route.request().url()).pathname; + if (u.endsWith("/abbruch-vorschau")) { + vorschauGeholt++; + return route.fulfill({ json: { ok: true, + projekt: { id: "p1", nummer: PROJEKT.nummer, uhrLaeuft: true, schonAbgebrochen: false }, + rechnung: { prozent: 25, fertig: 5, gesamt: 20, + bezahltCent: 60000, bezahltText: "600,00 €", + behaltenCent: 50000, behaltenText: "500,00 €", + erstattungCent: 10000, erstattungText: "100,00 €" } } }); + } + if (u.endsWith("/abbrechen")) { + gesendet = route.request().postDataJSON(); + return route.fulfill({ json: { ok: true, grund: gesendet.grund, + erstattungCent: Math.round((gesendet.erstattungEuro || 0) * 100), + hinweis: "Zu erstatten: 100,00 €. Die Rückzahlung löst die Seite bewusst nicht selbst aus." } }); + } + if (u.endsWith("/admin/uebersicht")) return route.fulfill({ json: UEBERSICHT }); + if (u.includes("/admin/projekte/p1/alles")) { + return route.fulfill({ json: { ok: true, projekt: PROJEKT, aufgaben: [], + fortschritt: { erledigt: 5, gesamt: 20, prozent: 25, inArbeit: "", beimKunden: "" }, + aenderungen: [], nachrichten: [] } }); + } + if (u.includes("/admin/projekte")) return route.fulfill({ json: { ok: true, projekte: [PROJEKT] } }); + return route.fulfill({ json: { ok: true, anfragen: [], kunden: [], zahlungen: [], verlaeufe: [] } }); + }); + 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:4359/webdesign/verwaltung.html"); + await seite.waitForTimeout(800); + console.log("\n=== " + wo + " ==="); + + /* ---- 1. Meins gegen seins: gemessen, nicht behauptet ---- */ + const farben = await seite.evaluate(() => { + const meins = document.querySelector("#vw-ub-beimir .vw-ub-kachel"); + const seins = document.querySelector("#vw-ub-beikunde .vw-ub-kachel"); + const st1 = getComputedStyle(meins), st2 = getComputedStyle(seins); + return { + meinZahl: st1.color, seinZahl: getComputedStyle(seins.querySelector(".vw-ub-zahl")).color, + meinZahlEcht: getComputedStyle(meins.querySelector(".vw-ub-zahl")).color, + meinKante: st1.borderLeftColor, seinKante: st2.borderLeftColor, + meinBreite: st1.borderLeftWidth, seinBreite: st2.borderLeftWidth, + }; + }); + pruefe(wo + ": die Zahlen haben unterschiedliche Farben", + farben.meinZahlEcht !== farben.seinZahl, + "meins " + farben.meinZahlEcht + " vs. beim Kunden " + farben.seinZahl); + pruefe(wo + ": auch die Kanten unterscheiden sich", + farben.meinKante !== farben.seinKante, + farben.meinKante + " vs. " + farben.seinKante); + /* Farbe allein trägt die Aussage nicht -- es muss auch eine sichtbare + Kante geben, die man ohne Farbunterscheidung wahrnimmt. */ + pruefe(wo + ": beide tragen eine sichtbare Kante, nicht nur Farbe", + parseFloat(farben.meinBreite) >= 3 && parseFloat(farben.seinBreite) >= 3, + farben.meinBreite + " / " + farben.seinBreite); + + /* ---- 2. Der Abbruch-Bereich ---- */ + await seite.click('[data-tab="projekte"]'); + await seite.waitForTimeout(500); + await seite.locator(".vw-karte").first().click(); + await seite.waitForTimeout(700); + + pruefe(wo + ": es gibt einen Abbruch-Knopf", + await seite.locator("#vw-abbruch-auf").isVisible()); + pruefe(wo + ": das Formular ist zunaechst zu", + !(await seite.locator("#vw-abbruch-form").isVisible())); + pruefe(wo + ": beim Oeffnen der Projektseite wurde NICHT schon gerechnet", + vorschauGeholt === 0, vorschauGeholt); + + await seite.click("#vw-abbruch-auf"); + await seite.waitForTimeout(700); + pruefe(wo + ": es klappt auf", await seite.locator("#vw-abbruch-form").isVisible()); + pruefe(wo + ": erst jetzt wird gerechnet", vorschauGeholt === 1, vorschauGeholt); + + const rechnung = await seite.locator("#vw-abbruch-rechnung").innerText(); + pruefe(wo + ": der Fortschritt steht da", /5 von 20/.test(rechnung), rechnung.replace(/\n/g, " | ")); + pruefe(wo + ": das Bezahlte steht da", /600,00/.test(rechnung), rechnung.replace(/\n/g, " | ")); + pruefe(wo + ": der Erstattungsvorschlag steht da", /100,00/.test(rechnung), rechnung.replace(/\n/g, " | ")); + pruefe(wo + ": und ist ins Feld uebernommen", + (await seite.locator("#vw-ab-erstattung").inputValue()) === "100.00", + await seite.locator("#vw-ab-erstattung").inputValue()); + + /* ---- 3. Der eingetragene Betrag muss gelten ---- */ + await seite.fill("#vw-ab-erstattung", "250"); + await seite.selectOption("#vw-ab-grund2", "keine_zahlung"); + await seite.selectOption("#vw-ab-wer", "kunde"); + await seite.fill("#vw-ab-notiz", "Dreimal erinnert, keine Reaktion."); + await seite.click("#vw-abbruch-los"); + await seite.waitForTimeout(900); + + pruefe(wo + ": der Abbruch geht hinaus", !!gesendet, JSON.stringify(gesendet)); + pruefe(wo + ": mit dem GEAENDERTEN Betrag, nicht dem Vorschlag", + gesendet && gesendet.erstattungEuro === 250, gesendet && gesendet.erstattungEuro); + pruefe(wo + ": mit dem gewaehlten Grund", + gesendet && gesendet.grund === "keine_zahlung", gesendet && gesendet.grund); + pruefe(wo + ": und der Angabe, wer abbricht", + gesendet && gesendet.wer === "kunde", gesendet && gesendet.wer); + pruefe(wo + ": die Notiz kommt mit", + gesendet && /Dreimal erinnert/.test(gesendet.notiz || ""), gesendet && gesendet.notiz); + + const m = await seite.locator("#vw-meldung").innerText().catch(() => ""); + pruefe(wo + ": der Hinweis sagt, dass die Rueckzahlung von Hand erfolgt", + /nicht selbst/.test(m), m); + + /* ---- 4. Ein bereits abgebrochenes Projekt bekommt keinen Knopf ---- */ + PROJEKT.status = "abgebrochen"; + PROJEKT.abbruch_am = new Date().toISOString(); + PROJEKT.abbruch_grund = "keine_zahlung"; + PROJEKT.abbruch_wer = "kunde"; + PROJEKT.abbruch_erstattung_cent = 25000; + await seite.click('[data-tab="projekte"]'); + await seite.waitForTimeout(500); + await seite.locator(".vw-karte").first().click(); + await seite.waitForTimeout(700); + pruefe(wo + ": ein abgebrochenes Projekt hat keinen Abbruch-Knopf mehr", + (await seite.locator("#vw-abbruch-auf").count()) === 0); + const info = await seite.locator(".vw-abbruch-info").innerText().catch(() => ""); + pruefe(wo + ": stattdessen steht da, was passiert ist", + /nicht gezahlt/.test(info) && /250,00/.test(info), info.replace(/\n/g, " | ")); + PROJEKT.status = "design"; + delete PROJEKT.abbruch_am; + + pruefe(wo + ": kein Skriptfehler", protokoll.length === 0, protokoll.join(" | ")); + 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; diff --git a/webdesign/sw.js b/webdesign/sw.js index 80d9d208..572b8e29 100644 --- a/webdesign/sw.js +++ b/webdesign/sw.js @@ -34,7 +34,7 @@ Start alle alten Zwischenspeicher weg. Muss bei jeder Änderung an den Dateien unten hochgezählt werden, sonst hängen Nutzer auf einem alten Stand fest. */ -const CACHE_NAME = "dogfather-webdesign-v27"; +const CACHE_NAME = "dogfather-webdesign-v28"; /* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE HTML-Datei in dieser Liste. */ diff --git a/webdesign/verwaltung.html b/webdesign/verwaltung.html index a4109e8b..daa4535f 100644 --- a/webdesign/verwaltung.html +++ b/webdesign/verwaltung.html @@ -123,6 +123,16 @@ .vw-meld-weg:focus-visible { outline: 2px solid rgb(var(--wd-blau-rgb) / .95); outline-offset: 2px; } .vw-meld-alle { margin: .1rem 0 1rem; } + .vw-abbruch-rechnung { + padding: .7rem .85rem; margin-bottom: .8rem; + border-radius: var(--wd-radius-m, 14px); + background: rgb(255 255 255 / .04); + border: 1px solid rgb(255 255 255 / .08); + } + .wd .vw-abbruch-rechnung p { font-size: .86rem; margin: 0 0 .25rem; } + .wd .vw-abbruch-vorschlag { color: var(--wd-gold); } + .vw-abbruch-info { border-left: 3px solid #e78278; padding-left: .8rem; } + .wd .vw-ub-h { font-size: 1.05rem; font-weight: 650; margin: 1.9rem 0 .75rem; @@ -140,6 +150,44 @@ grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); margin-bottom: .4rem; } + /* ================================================================ + MEINS ODER SEINS — auf einen Blick unterscheidbar + + Wunsch vom 24.08.2026: "ich will dass die kunden sachen auch in der + verwaltungs seite von kacheln eine andere farbe haben wie meine + damit ich sie gut unterscheide." + + Umgesetzt über den TON, nicht über die Warnfarbe: Was bei mir liegt, + bleibt im Markenblau — das ist meine Arbeit. Was beim Kunden liegt, + bekommt Lila. Der Unterschied ist deutlich, ohne dass eine der + beiden Seiten nach Alarm aussieht. + + Warum nicht Rot/Grün: Die Warnstufen (gelb, rot) sind an das ALTER + vergeben und müssen frei bleiben. Eine Kachel, die gleichzeitig + "beim Kunden" und "seit acht Tagen überfällig" färben müsste, + könnte nur eine der beiden Aussagen zeigen — und die Frist ist die + wichtigere. + + Zusätzlich zur Farbe eine Kante links. Farbe allein trägt die + Aussage nicht: Wer sie nicht unterscheiden kann, sähe sonst zwei + gleich aussehende Blöcke (WCAG 1.4.1). */ + #vw-ub-beikunde .vw-ub-kachel { + border-color: rgb(var(--wd-lila-rgb) / .28); + border-left: 3px solid rgb(var(--wd-lila-rgb) / .55); + } + #vw-ub-beikunde .vw-ub-kachel .vw-ub-zahl { color: var(--wd-lila); } + #vw-ub-beikunde .vw-ub-kachel:hover:not(:disabled) { + border-color: rgb(var(--wd-lila-rgb) / .5); + } + /* Auch die Null-Kachel bleibt zugeordnet — sonst wäre ausgerechnet + die gute Nachricht die einzige ohne erkennbare Herkunft. */ + #vw-ub-beikunde .vw-ub-kachel--leer { border-left-color: rgb(var(--wd-lila-rgb) / .3); } + /* Meine Seite bekommt spiegelbildlich eine blaue Kante, damit der + Unterschied ein Gegensatz ist und nicht "eine Gruppe ist markiert". */ + #vw-ub-beimir .vw-ub-kachel { border-left: 3px solid rgb(var(--wd-blau-rgb) / .5); } + #vw-ub-beimir .vw-ub-kachel--warn { border-left-color: #e9c46a; } + #vw-ub-beimir .vw-ub-kachel--rot { border-left-color: #e78278; } + @media (max-width: 560px) { /* Zwei Spalten statt einer. @@ -1333,6 +1381,11 @@ shop: "Shop & umfangreiche Website", verwaltung: "Buchhaltungs- & Steuerverwaltung", betreuung: "Laufende Betreuung", unklar: "Noch unklar", }; + var ABBRUCH_TEXT = { + keine_zahlung: "nicht gezahlt", kunde_abgesprungen: "Kunde abgesprungen", + keine_mitwirkung: "keine Mitwirkung", einvernehmlich: "einvernehmlich", + mein_ruecktritt: "mein Rücktritt", sonstiges: "sonstiges", + }; var VORHANDEN_TEXT = { ja: "vorhanden", teilweise: "teilweise", nein: "fehlt noch" }; var BUDGET_TEXT = { "bis-500": "bis 500 €", "500-1000": "500 – 1.000 €", @@ -3193,10 +3246,118 @@ '' + "" + + /* ----- ABBRECHEN ----- + + Steht ganz unten und zurückhaltend gestaltet: Es ist die + seltenste und die endgültigste Handlung an einem Projekt. Ein + gleich lauter Knopf zwischen den anderen lädt zum Verwechseln + ein. */ + (p.status === "abgebrochen" + ? '

Abgebrochen

' + + '

' + + (p.abbruch_am ? s(window.WD.datum(p.abbruch_am)) : "") + + (p.abbruch_grund ? " · Grund: " + s(ABBRUCH_TEXT[p.abbruch_grund] || p.abbruch_grund) : "") + + (p.abbruch_wer ? " · durch " + (p.abbruch_wer === "kunde" ? "den Kunden" : "dich") : "") + + "

" + + (p.abbruch_erstattung_cent + ? '

Zu erstatten war: ' + + (p.abbruch_erstattung_cent / 100).toFixed(2).replace(".", ",") + " €

" + : '

Es war nichts zu erstatten.

') + + "
" + : '

Auftrag abbrechen

' + + '

Wenn der Kunde nicht zahlt, sich nicht mehr meldet ' + + 'oder selbst abspringt. Die Seite rechnet dir vor, wie viel Arbeit erledigt ist, ' + + 'und schlägt daraus einen Erstattungsbetrag vor.

' + + '" + + '
") + + ""; blatt.querySelector(".vw-zu").addEventListener("click", schliesseDetail); + /* ---- Abbrechen ---- */ + var abbAuf = blatt.querySelector("#vw-abbruch-auf"); + if (abbAuf) { + var abbForm = blatt.querySelector("#vw-abbruch-form"); + abbAuf.addEventListener("click", async function () { + abbForm.hidden = !abbForm.hidden; + abbAuf.textContent = abbForm.hidden ? "Abbruch vorbereiten" : "Doch nicht abbrechen"; + if (abbForm.hidden) return; + /* Erst beim Aufklappen rechnen lassen. Die Zahlen sollen den + Stand von JETZT zeigen, nicht den vom Öffnen der Projektseite + — dazwischen kann man Punkte abgehakt haben. */ + try { + var v = await api("/webdesign/admin/projekte/" + encodeURIComponent(p.id) + "/abbruch-vorschau"); + var r = v.rechnung || {}; + blatt.querySelector("#vw-abbruch-rechnung").innerHTML = + "

" + r.fertig + " von " + r.gesamt + " Schritten erledigt (" + + r.prozent + " %)

" + + "

Bezahlt: " + s(r.bezahltText || "0,00 €") + " · davon verdient: " + + s(r.behaltenText || "0,00 €") + "

" + + '

Vorschlag zur Erstattung: ' + + s(r.erstattungText || "0,00 €") + "

"; + blatt.querySelector("#vw-ab-erstattung").value = + ((r.erstattungCent || 0) / 100).toFixed(2); + } catch (f) { + /* Ohne Vorschau trotzdem abbrechen können: Der Betrag lässt + sich von Hand eintragen. Ein ausgefallener Rechendienst darf + kein Projekt in der Liste festhalten. */ + blatt.querySelector("#vw-abbruch-rechnung").innerHTML = + '

Die Vorschau konnte nicht geladen werden: ' + s(f.message) + + " Du kannst den Betrag von Hand eintragen.

"; + } + }); + + blatt.querySelector("#vw-abbruch-los").addEventListener("click", async function (ev) { + /* Nachfragen: Danach ist das Projekt abgebrochen, und offene + Rechnungen sind storniert. */ + if (!confirm("Auftrag " + p.nummer + " wirklich abbrechen? Offene Rechnungen werden storniert.")) return; + window.WD.knopfSperren(ev.target, true, "Wird abgebrochen…"); + try { + var a = await api("/webdesign/admin/projekte/" + encodeURIComponent(p.id) + "/abbrechen", { + method: "POST", + body: { + grund: blatt.querySelector("#vw-ab-grund2").value, + wer: blatt.querySelector("#vw-ab-wer").value, + erstattungEuro: Number(blatt.querySelector("#vw-ab-erstattung").value) || 0, + notiz: blatt.querySelector("#vw-ab-notiz").value, + }, + }); + schliesseDetail(); + await ladeProjekte(); + window.WD.meldung(meldung, a.hinweis || "Abgebrochen.", "ok"); + } catch (f) { + window.WD.knopfSperren(ev.target, false); + window.WD.meldung(meldung, "Abbrechen fehlgeschlagen: " + f.message, "fehler"); + } + }); + } + /* Zustand weiterschalten. */ blatt.querySelectorAll("[data-weiter]").forEach(function (b) { b.addEventListener("click", function () {