From 87261100354ed4fac405a42b58ceea2074fcf893 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Mon, 24 Aug 2026 12:23:44 +0200 Subject: [PATCH] Angebote in der Oberflaeche: schicken und im Portal zusagen VERWALTUNG Der Angebotsbereich steht in der Anfrage-Detailansicht ueber dem direkten Annehmen. Der uebliche Weg ist: Angebot raus, Kunde sagt zu, Projekt entsteht -- direkt anzunehmen ist der Sonderfall (man hat schon telefoniert). Stuende der Sonderfall oben, waere er der naheliegende Griff, und man verschenkte den Beleg, den eine Zusage im Portal erzeugt. Vorbelegt sind Paket, Preis, Anzahlung, Laufzeit, Ablaufdatum und der komplette Leistungstext. Zu tun bleibt: Paket bestaetigen, Preis bestaetigen. Der Leistungstext ist eingeklappt statt die Seite zu fluten, aber aenderbar. Ein Paketwechsel laedt ALLES neu -- auch den Leistungstext. Ein stehengebliebener Text vom vorigen Paket waere der schlimmste Fall: Er ginge unbemerkt hinaus, und im Angebot staende etwas, das zum Preis nicht passt. Genau das prueft der Test. PORTAL Die Angebotskarte steht ganz oben, vor allen Projekten: Ein Angebot ist das Einzige im Portal, das eine ENTSCHEIDUNG verlangt -- alles andere ist Auskunft. Der eingefrorene Leistungstext wird lesbar aufbereitet (Zwischenueberschriften, Punkte), nicht als Textblock hingeworfen. Vor der Zusage wird gefragt, und die Frage nennt die Folgen ('verbindlich', 'Anzahlung'). Nicht aus Foermlichkeit: Hier entsteht ein Vertrag, und ein Knopf, der beim Danebentippen einen Auftrag ausloest, waere eine Falle. Beim Ablehnen wird NICHT gefragt -- das ist folgenlos. ZWEI FEHLER GEFUNDEN, BEIDE IM NORMALFALL - Die Angebotskarten standen im Zweig 'es gibt Projekte'. Ein Kunde mit offenem Angebot hat aber typischerweise noch KEIN Projekt -- genau der Normalfall -- und saeh damit nichts. Der Server lieferte das Angebot, die Seite zeichnete es nur nie. Nur im Browsertest sichtbar. - Eine Antwort ohne 'nachrichten' liess das Postfach werfen, und weil der Fehler den Aufbau abbrach, erschien danach GAR NICHTS mehr -- auch nicht das Angebot, das ueber allem stehen sollte. Dieselbe Sorte wie zuvor im Cockpit: Ein halber Server ist ein realistischer Fall. Neun Sprachschluessel in fuenf Sprachen ergaenzt. Geprueft mit 54 Pruefungen auf Computer und Handy, die messen, was tatsaechlich an den Server geht. Alle bestehenden weiter gruen. Co-Authored-By: Claude Opus 5 --- assets/js/i18n-wd-portal.js | 37 ++++++ assets/js/wd-portal.js | 129 ++++++++++++++++++- pruef-angebot.mjs | 241 ++++++++++++++++++++++++++++++++++++ webdesign/portal.html | 42 +++++++ webdesign/sw.js | 2 +- webdesign/verwaltung.html | 126 ++++++++++++++++++- 6 files changed, 573 insertions(+), 4 deletions(-) create mode 100644 pruef-angebot.mjs diff --git a/assets/js/i18n-wd-portal.js b/assets/js/i18n-wd-portal.js index 50448717..30a62337 100644 --- a/assets/js/i18n-wd-portal.js +++ b/assets/js/i18n-wd-portal.js @@ -186,6 +186,43 @@ window.I18N_WD = { pt: "O Dogfather está a tratar", }, po_richttermin: { de: "Richttermin", "de-CH": "Richttermin", en: "Target date", fr: "Date cible", pt: "Data prevista" }, + po_ag_neu: { de: "Angebot", "de-CH": "Angebot", en: "Offer", fr: "Devis", pt: "Proposta" }, + po_ag_angenommen: { de: "angenommen", "de-CH": "angenommen", en: "accepted", fr: "accepté", pt: "aceite" }, + po_ag_abgelaufen: { de: "abgelaufen", "de-CH": "abgelaufen", en: "expired", fr: "expiré", pt: "expirado" }, + po_ag_anzahlung: { + de: "Anzahlung zum Start: {betrag} (30 %)", + "de-CH": "Anzahlung zum Start: {betrag} (30 %)", + en: "Deposit to start: {betrag} (30%)", + fr: "Acompte au démarrage : {betrag} (30 %)", + pt: "Sinal para começar: {betrag} (30 %)" }, + po_ag_dauer: { + de: "Umsetzung in etwa {n} Werktagen ab Zahlungseingang", + "de-CH": "Umsetzung in etwa {n} Werktagen ab Zahlungseingang", + en: "Delivery in about {n} working days from receipt of payment", + fr: "Réalisation en environ {n} jours ouvrables après réception du paiement", + pt: "Execução em cerca de {n} dias úteis após a receção do pagamento" }, + po_ag_gueltig: { + de: "Gültig bis {datum}", + "de-CH": "Gültig bis {datum}", + en: "Valid until {datum}", fr: "Valable jusqu’au {datum}", pt: "Válido até {datum}" }, + po_ag_zusagen: { + de: "Angebot annehmen", "de-CH": "Angebot annehmen", + en: "Accept offer", fr: "Accepter le devis", pt: "Aceitar proposta" }, + po_ag_ablehnen: { + de: "Doch nicht", "de-CH": "Doch nicht", + en: "Not right now", fr: "Pas pour l’instant", pt: "Agora não" }, + po_ag_abgelaufen_text: { + de: "Dieses Angebot ist abgelaufen. Schreib mir kurz — ich mache dir gern ein neues.", + "de-CH": "Dieses Angebot ist abgelaufen. Schreib mir kurz — ich mache dir gern ein neues.", + en: "This offer has expired. Just message me and I will gladly make you a new one.", + fr: "Ce devis a expiré. Écrivez-moi, je vous en établirai volontiers un nouveau.", + pt: "Esta proposta expirou. Escreva-me e farei com gosto uma nova." }, + po_ag_sicher: { + de: "Angebot verbindlich annehmen? Danach lege ich dein Projekt an und du bekommst die Anzahlung als Rechnung.", + "de-CH": "Angebot verbindlich annehmen? Danach lege ich dein Projekt an und du bekommst die Anzahlung als Rechnung.", + en: "Accept this offer bindingly? I will then set up your project and you will receive the deposit invoice.", + fr: "Accepter ce devis de manière ferme ? Je créerai ensuite votre projet et vous recevrez la facture d’acompte.", + pt: "Aceitar esta proposta de forma vinculativa? Depois crio o seu projeto e receberá a fatura do sinal." }, po_liefertermin: { de: "Liefertermin", "de-CH": "Liefertermin", en: "Delivery date", fr: "Date de livraison", pt: "Data de entrega" }, po_noch_tage: { de: "noch {n} Werktage", "de-CH": "noch {n} Werktage", en: "{n} working days left", fr: "encore {n} jours ouvrables", pt: "faltam {n} dias úteis" }, po_noch_ein_tag: { de: "noch 1 Werktag", "de-CH": "noch 1 Werktag", en: "1 working day left", fr: "encore 1 jour ouvrable", pt: "falta 1 dia útil" }, diff --git a/assets/js/wd-portal.js b/assets/js/wd-portal.js index 77b10c9c..ad8cef2d 100644 --- a/assets/js/wd-portal.js +++ b/assets/js/wd-portal.js @@ -203,6 +203,70 @@ return stuecke.join(""); } + /* --------------------------------------------------------------------- + ANGEBOTE + + Stehen GANZ oben, vor allen Projekten. Ein Angebot ist das Einzige + im Portal, das eine Entscheidung verlangt — alles andere ist + Auskunft. Stünde es zwischen den laufenden Projekten, sähe man es + erst nach dem Scrollen, und der Zusage-Knopf wäre eine Fussnote. + + Der Leistungstext kommt FERTIG vom Server (er wurde beim Absenden + eingefroren). Hier wird er nur noch dargestellt: **fett** wird zur + Zwischenüberschrift, Zeilen mit · zu Punkten. Absichtlich keine + allgemeine Auszeichnungssprache — es soll genau das erscheinen, was + geschickt wurde, und nichts, was ein Text zufällig auslöst. + --------------------------------------------------------------------- */ + function leistungenZeichnen(text) { + if (!text) return ""; + return text.split("\n").map(function (z) { + var t = z.trim(); + if (!t) return ""; + var fett = t.match(/^\*\*(.+)\*\*$/); + if (fett) return '

' + s(fett[1]) + "

"; + if (t.indexOf("\u00b7") === 0) return '
  • ' + s(t.slice(1).trim()) + "
  • "; + return "

    " + s(t) + "

    "; + }).join("").replace(/(
  • .*?<\/li>)+/g, function (m) { + return '
      ' + m + "
    "; + }); + } + + function angebotsKarten(angebote) { + if (!angebote || !angebote.length) return ""; + return angebote.map(function (a) { + var offen = a.status === "gesendet"; + return '
    ' + + '' + + (a.status === "angenommen" ? s(window.WD.t("po_ag_angenommen")) + : a.abgelaufen ? s(window.WD.t("po_ag_abgelaufen")) + : s(window.WD.t("po_ag_neu"))) + "" + + "

    " + s(a.titel) + "

    " + + '

    ' + s(a.preis_text) + "

    " + + '

    ' + s(window.WD.t("po_ag_anzahlung")) + .replace("{betrag}", s(a.anzahlung_text)) + "

    " + + (a.laufzeit_werktage + ? '

    ' + s(window.WD.t("po_ag_dauer")) + .replace("{n}", a.laufzeit_werktage) + "

    " + : "") + + '
    ' + leistungenZeichnen(a.leistungen_text) + "
    " + + (a.hinweis_text ? '

    ' + s(a.hinweis_text) + "

    " : "") + + (offen && a.annehmbar + ? '

    ' + s(window.WD.t("po_ag_gueltig")) + .replace("{datum}", s(a.gueltig_bis || "")) + "

    " + + '
    ' + + '" + + '" + + "
    " + : a.abgelaufen + ? '

    ' + s(window.WD.t("po_ag_abgelaufen_text")) + "

    " + : "") + + "
    "; + }).join(""); + } + function wartetMarke(wartet) { if (wartet !== "kunde" && wartet !== "dogfather") return ""; return '' + @@ -359,6 +423,15 @@ } html += "

    " + s(window.WD.t("po_projekte")) + "

    "; + /* Angebote stehen VOR der Weiche, nicht in einem ihrer Zweige. + + Der erste Entwurf setzte sie in den Zweig "es gibt Projekte". + Ein Kunde mit einem offenen Angebot hat aber typischerweise noch + KEIN Projekt -- genau der Normalfall -- und saeh damit nichts. + Aufgefallen erst im Browsertest: Der Server lieferte das Angebot, + die Seite zeichnete es nur nie. */ + html += angebotsKarten(d.angebote); + if (!d.projekte.length) { /* Ein leerer Bereich mit einem Satz ist eine verpasste Gelegenheit. Wer hier zum ersten Mal landet, hat gerade sein Passwort gesetzt @@ -502,10 +575,20 @@ '
    ' + '

    ' + s(window.WD.t("po_postfach_text")) + "

    "; - if (!d.nachrichten.length) { + /* Fehlt die Liste ganz, wird daraus eine leere -- statt eines + Fehlers, der die ganze Portalseite mitreisst. + + Aufgefallen am 24.08.2026: Eine Antwort ohne 'nachrichten' liess + das Postfach werfen, und weil der Fehler den Aufbau abbrach, + erschien danach GAR NICHTS mehr -- auch nicht das Angebot, das + ueber allem stehen sollte. Dieselbe Sorte Fehler wie zuvor im + Cockpit der Verwaltung: Ein halber Server ist ein realistischer + Fall, und dann soll der Rest der Seite trotzdem stehen. */ + var nachrichten = d.nachrichten || []; + if (!nachrichten.length) { html += '

    ' + s(window.WD.t("po_postfach_leer")) + "

    "; } else { - html += '
    ' + d.nachrichten.map(function (n) { + html += '
    ' + nachrichten.map(function (n) { var vonMir = n.autor === "kunde"; return '
    ' + '
    ' + @@ -841,4 +924,46 @@ if (window.WD) start(); else document.addEventListener("wd-bereit", start); + + /* --------------------------------------------------------------------- + ZUSAGE UND ABLEHNUNG + + Über Ereignis-Delegation am Dokument, weil die Karten bei jedem + Laden neu entstehen — einzeln angehängte Zuhörer wären nach der + ersten Aktualisierung verschwunden. + + Vor der Zusage wird gefragt. Nicht aus Förmlichkeit: Hier entsteht + ein Vertrag, und ein Knopf, der beim Danebentippen einen Auftrag + auslöst, wäre eine Falle. Beim Ablehnen wird NICHT gefragt — das + ist folgenlos und jederzeit umkehrbar (man schreibt einfach). + --------------------------------------------------------------------- */ + document.addEventListener("click", async function (e) { + var ja = e.target.closest ? e.target.closest("[data-zusagen]") : null; + var nein = e.target.closest ? e.target.closest("[data-ablehnen]") : null; + if (!ja && !nein) return; + + var id = (ja || nein).dataset[ja ? "zusagen" : "ablehnen"]; + var knopf = ja || nein; + + if (ja && !confirm(window.WD.t("po_ag_sicher"))) return; + + window.WD.knopfSperren(knopf, true, "…"); + try { + var r = await window.WD.api( + "/webdesign/portal/angebote/" + encodeURIComponent(id) + (ja ? "/zusagen" : "/ablehnen"), + { method: "POST", body: {} }); + if (ja) { + /* Nach der Zusage vollständig neu laden: Es ist ein Projekt + entstanden, eine Rechnung, und das Angebot hat einen anderen + Zustand. Einzelne Stellen nachzuziehen hiesse, jede davon + einzeln richtig zu treffen. */ + alert(r.hinweis || "Danke!"); + } + location.reload(); + } catch (f) { + window.WD.knopfSperren(knopf, false); + alert(f.message || "Das hat leider nicht geklappt."); + } + }); + })(); diff --git a/pruef-angebot.mjs b/pruef-angebot.mjs new file mode 100644 index 00000000..e1bfa5b2 --- /dev/null +++ b/pruef-angebot.mjs @@ -0,0 +1,241 @@ +/* ===================================================================== + pruef-angebot.mjs — Angebot rausschicken und im Portal zusagen + + Zwei Seiten, ein Vorgang. Geprüft wird jeweils, WAS an den Server + geht — ein Dialog, der gut aussieht und den falschen Preis sendet, + besteht jede Sichtprüfung. + + Der heikelste Punkt: Wechselt man das Paket, müssen Preis UND + Leistungstext neu geladen werden. Ein stehengebliebener Text vom + vorigen Paket ginge unbemerkt hinaus — im Angebot stünde dann etwas, + das zum Preis nicht passt. + ===================================================================== */ +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(4361, r)); + +const ergebnisse = []; +const pruefe = (n, ok, z) => { + ergebnisse.push({ n, ok }); + console.log((ok ? " ok " : " FEHL ") + n + (z !== undefined ? " -> " + z : "")); +}; + +const LEIST_WEBSITE = "**Aufbau**\n· Startseite gestalten\n· Unterseiten anlegen\n\n" + + "**Was ich von dir brauche**\n· Texte\n· Bilder"; +const LEIST_SHOP = "**Shop**\n· Produkte anlegen\n· Bezahlung einrichten\n\n" + + "**Was ich von dir brauche**\n· Produktdaten"; + +const ANFRAGE = { + id: "t1", nummer: "A-2608-0007", name: "Anna Muster", email: "anna@example.com", + paket: "website", status: "neu", sprache: "de", archiviert: 0, kunde_id: null, + details: "Bitte modern.", erstellt_am: new Date().toISOString(), +}; + +/* ---------- TEIL 1: Verwaltung ---------- */ +for (const [wo, angaben] of [ + ["Computer", { viewport: { width: 1440, height: 950 } }], + ["Handy", { ...devices["iPhone 14 Pro"] }], +]) { + const browser = await chromium.launch(); + const seite = await (await browser.newContext(angaben)).newPage(); + const protokoll = []; + seite.on("pageerror", (e) => protokoll.push(e.message)); + let gesendet = null, vorschauen = []; + + await seite.route("**/webdesign/admin/**", (route) => { + const u = new URL(route.request().url()); + if (u.pathname.endsWith("/angebot-vorschau")) { + const paket = u.searchParams.get("paket") || "website"; + vorschauen.push(paket); + return route.fulfill({ json: { ok: true, paket, + titel: "Website für Anna Muster", + preisEuro: paket === "shop" ? 2990 : 1490, + anzahlungEuro: paket === "shop" ? 897 : 447, + werktage: paket === "shop" ? 30 : 20, + gueltigBis: "2026-09-21", + leistungen: paket === "shop" ? LEIST_SHOP : LEIST_WEBSITE, + sprache: "de", kunde: { name: "Anna Muster" }, schonUnterwegs: null } }); + } + if (u.pathname.endsWith("/angebot")) { + gesendet = route.request().postDataJSON(); + return route.fulfill({ json: { ok: true, id: "ag1", nummer: "AG-2608-0001", + preisText: "2.990,00 €", anzahlungText: "897,00 €", gueltigBis: "2026-09-21", + hinweis: "Das Angebot liegt im Portal des Kunden." } }); + } + if (u.pathname.endsWith("/admin/uebersicht")) { + return route.fulfill({ json: { ok: true, + beiMir: { anfragen: { n: 1 }, 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: {}, meldungen: [], wartetAufAnzahlung: [] } }); + } + if (u.pathname.includes("/admin/anfragen/t1")) { + return route.fulfill({ json: { ok: true, anfrage: ANFRAGE, notizen: [] } }); + } + if (u.pathname.includes("/admin/anfragen")) return route.fulfill({ json: { ok: true, anfragen: [ANFRAGE] } }); + return route.fulfill({ json: { ok: true, kunden: [], projekte: [], 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:4361/webdesign/verwaltung.html"); + await seite.waitForTimeout(700); + console.log("\n=== Verwaltung · " + wo + " ==="); + + await seite.click('[data-tab="anfragen"]'); + await seite.waitForTimeout(500); + await seite.locator(".vw-karte").first().click(); + await seite.waitForTimeout(900); + + pruefe(wo + ": der Angebotsbereich ist da", await seite.locator("#vw-angebot").isVisible()); + pruefe(wo + ": die Vorschau wurde geholt", vorschauen.length === 1, vorschauen.join(",")); + pruefe(wo + ": das Paket ist aus der Anfrage vorbelegt", + (await seite.locator("#vw-ag-paket").inputValue()) === "website", + await seite.locator("#vw-ag-paket").inputValue()); + pruefe(wo + ": der Preis ist vorbelegt", + (await seite.locator("#vw-ag-preis").inputValue()) === "1490", + await seite.locator("#vw-ag-preis").inputValue()); + pruefe(wo + ": die Anzahlung wird angezeigt", + /447,00/.test(await seite.locator("#vw-ag-anzahlung").innerText()), + await seite.locator("#vw-ag-anzahlung").innerText()); + pruefe(wo + ": Laufzeit und Ablauf stehen da", + /20 Werktage/.test(await seite.locator("#vw-ag-gueltig").innerText()), + await seite.locator("#vw-ag-gueltig").innerText()); + /* Der Leistungstext ist vorbelegt -- das ist der Kern von "so wenig + wie moeglich schreiben". */ + const leist = await seite.locator("#vw-ag-leistungen").inputValue(); + pruefe(wo + ": der Leistungsumfang ist vorbelegt", leist.includes("Startseite"), leist.slice(0, 50)); + pruefe(wo + ": und ist eingeklappt, statt die Seite zu fluten", + !(await seite.locator("#vw-ag-leistungen").isVisible())); + + /* ---- Paketwechsel: der heikle Punkt ---- */ + await seite.selectOption("#vw-ag-paket", "shop"); + await seite.waitForTimeout(700); + pruefe(wo + ": ein Paketwechsel laedt neu", vorschauen.length === 2, vorschauen.join(",")); + pruefe(wo + ": der Preis passt sich an", + (await seite.locator("#vw-ag-preis").inputValue()) === "2990", + await seite.locator("#vw-ag-preis").inputValue()); + const leist2 = await seite.locator("#vw-ag-leistungen").inputValue(); + pruefe(wo + ": und der Leistungstext wechselt MIT", + leist2.includes("Produkte anlegen") && !leist2.includes("Startseite"), leist2.slice(0, 60)); + + await seite.click("#vw-ag-senden"); + await seite.waitForTimeout(900); + pruefe(wo + ": das Angebot geht hinaus", !!gesendet, JSON.stringify(gesendet).slice(0, 90)); + pruefe(wo + ": mit dem gewechselten Paket", gesendet && gesendet.paket === "shop", gesendet && gesendet.paket); + pruefe(wo + ": mit dem passenden Preis", gesendet && gesendet.preisEuro === 2990, gesendet && gesendet.preisEuro); + pruefe(wo + ": und dem passenden Leistungstext", + gesendet && /Produkte anlegen/.test(gesendet.leistungen || ""), + (gesendet && gesendet.leistungen || "").slice(0, 40)); + + const m = await seite.locator("#vw-meldung").innerText().catch(() => ""); + pruefe(wo + ": die Meldung nennt Nummer und Betraege", + /AG-2608-0001/.test(m) && /2.990,00/.test(m) && /897,00/.test(m), m); + pruefe(wo + ": kein Skriptfehler", protokoll.length === 0, protokoll.join(" | ")); + await browser.close(); +} + +/* ---------- TEIL 2: Portal ---------- */ +const ANGEBOT = { + id: "ag1", nummer: "AG-2608-0001", paket: "shop", titel: "Onlineshop für Anna Muster", + preis_cent: 299000, anzahlung_cent: 89700, waehrung: "EUR", laufzeit_werktage: 30, + leistungen_text: LEIST_SHOP, hinweis_text: null, status: "gesendet", + gueltig_bis: "2026-09-21", preis_text: "2.990,00 €", anzahlung_text: "897,00 €", + annehmbar: true, abgelaufen: false, +}; + +for (const [wo, angaben] of [ + ["Computer", { viewport: { width: 1440, height: 950 } }], + ["Handy", { ...devices["iPhone 14 Pro"] }], +]) { + const browser = await chromium.launch(); + const seite = await (await browser.newContext(angaben)).newPage(); + const protokoll = []; + seite.on("pageerror", (e) => protokoll.push(e.message)); + let zugesagt = null, gefragt = null; + /* Nur den ERSTEN Dialog festhalten. Es erscheinen zwei: die + Rueckfrage vor der Zusage und danach die Bestaetigung. Die erste + Fassung ueberschrieb die Rueckfrage mit der Bestaetigung und pruefte + damit den falschen Text. */ + seite.on("dialog", (d) => { if (gefragt === null) gefragt = d.message(); d.accept(); }); + + await seite.route("**/webdesign/portal/**", (route) => { + const u = new URL(route.request().url()).pathname; + if (u.endsWith("/zusagen")) { + zugesagt = u; + return route.fulfill({ json: { ok: true, projektNummer: "P-2608-0001", + anzahlungText: "897,00 €", hinweis: "Danke! Dein Projekt ist angelegt." } }); + } + if (u.endsWith("/uebersicht")) { + return route.fulfill({ json: { ok: true, + kunde: { name: "Anna Muster", email: "anna@example.com", sprache: "de" }, + angebote: [ANGEBOT], projekte: [], abos: [], offeneZahlungen: [], + dateien: [], nachrichten: [] } }); + } + /* Das Postfach braucht seine eigene Antwort. Ohne sie fiel es in + diesen Auffang, bekam ein blankes {ok:true} -- und riss frueher + die ganze Seite mit. Seit dem 24.08.2026 haelt es das aus; die + richtige Antwort steht hier trotzdem, damit der Test den + Normalfall prueft und nicht den Notfall. */ + if (u.endsWith("/postfach")) return route.fulfill({ json: { ok: true, nachrichten: [] } }); + return route.fulfill({ json: { ok: true, nachrichten: [], dateien: [] } }); + }); + await seite.addInitScript(() => { + sessionStorage.setItem("wd-portal-token", "t"); + sessionStorage.setItem("wd-sitzung-aktiv", "1"); + }); + + await seite.goto("http://127.0.0.1:4361/webdesign/portal.html"); + await seite.waitForTimeout(900); + console.log("\n=== Portal · " + wo + " ==="); + + const karte = seite.locator("[data-angebot]").first(); + const daIst = await karte.count(); + pruefe(wo + ": die Angebotskarte erscheint", daIst === 1, daIst); + if (daIst) { + const txt = await karte.innerText(); + pruefe(wo + ": der Preis steht gross da", /2\.990,00/.test(txt), txt.split("\n").slice(0, 4).join(" | ")); + pruefe(wo + ": die Anzahlung wird genannt", /897,00/.test(txt)); + pruefe(wo + ": die Dauer wird genannt", /30 Werktagen/.test(txt), txt.match(/.*Werktagen.*/) || ""); + pruefe(wo + ": der Leistungsumfang ist lesbar aufbereitet", + (await karte.locator(".po-ag-gruppe").count()) >= 1 + && (await karte.locator(".po-ag-liste li").count()) >= 2, + "Gruppen " + (await karte.locator(".po-ag-gruppe").count()) + + ", Punkte " + (await karte.locator(".po-ag-liste li").count())); + pruefe(wo + ": die Gueltigkeit steht dabei", /21\.09\.2026|2026-09-21/.test(txt), txt); + + /* Vor der Zusage MUSS gefragt werden -- hier entsteht ein Vertrag. */ + await seite.locator("[data-zusagen]").first().click(); + await seite.waitForTimeout(900); + pruefe(wo + ": vor der Zusage wird gefragt", !!gefragt, String(gefragt).slice(0, 70)); + pruefe(wo + ": die Frage nennt die Folgen", + /verbindlich/.test(gefragt || "") && /Anzahlung/.test(gefragt || ""), gefragt); + pruefe(wo + ": die Zusage geht hinaus", !!zugesagt, String(zugesagt)); + } + 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/portal.html b/webdesign/portal.html index 86245ff7..ac10ab89 100644 --- a/webdesign/portal.html +++ b/webdesign/portal.html @@ -47,6 +47,48 @@ entspannt laeuft, soll keine Alarmfarbe sehen. Erst wenn es eng wird, kommt Farbe dazu -- und der TEXT sagt es ebenfalls, damit die Aussage auch ankommt, wenn man Farben nicht unterscheiden kann. */ + /* ---------- Angebot ---------- + + Deutlich hervorgehoben, aber nicht schreiend: Es ist das Einzige im + Portal, das eine Entscheidung verlangt, und es soll auffallen — + ohne dass die Seite dabei nach Werbung aussieht. Deshalb ein + ruhiger Goldton am Rand statt einer farbigen Fläche. */ + .po-angebot { position: relative; } + .po-angebot--offen { + border-color: rgb(var(--wd-gold-rgb) / .45); + box-shadow: 0 0 0 1px rgb(var(--wd-gold-rgb) / .12), var(--wd-hoehe-1); + } + .po-ag-marke { + display: inline-block; margin-bottom: .5rem; + padding: .16rem .6rem; border-radius: 999px; + font-size: .74rem; font-weight: 800; letter-spacing: .04em; + text-transform: uppercase; + background: rgb(var(--wd-gold-rgb) / .16); color: var(--wd-gold); + } + .wd .po-ag-preis { + font-size: clamp(1.5rem, 4vw, 2rem); font-weight: 700; + color: var(--wd-blau); margin: .2rem 0 .3rem; + font-variant-numeric: tabular-nums; + } + .po-ag-leistungen { margin: 1rem 0; } + .wd .po-ag-gruppe { + font-size: .9rem; font-weight: 700; margin: .9rem 0 .3rem; + color: var(--wd-text); + } + .po-ag-liste { margin: 0 0 .2rem; padding-left: 1.1rem; } + .wd .po-ag-liste li { font-size: .88rem; margin-bottom: .2rem; color: var(--wd-text-soft); } + .po-ag-knoepfe { + display: flex; flex-wrap: wrap; gap: .6rem; align-items: center; + margin-top: 1rem; padding-top: .9rem; + border-top: 1px solid var(--wd-rand); + } + /* Der Zusage-Knopf nimmt auf dem Handy die volle Breite — er ist das + Ziel dieser Karte, und ein halbbreiter Knopf neben einem + Abbrechen-Knopf lädt zum Verwechseln ein. */ + @media (max-width: 560px) { + .po-ag-knoepfe > .wd-btn--haupt { width: 100%; } + } + .po-frist { display: inline-block; margin-left: .35rem; padding: .1rem .5rem; border-radius: 999px; diff --git a/webdesign/sw.js b/webdesign/sw.js index 572b8e29..1f02dfc2 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-v28"; +const CACHE_NAME = "dogfather-webdesign-v29"; /* 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 daa4535f..83f9e055 100644 --- a/webdesign/verwaltung.html +++ b/webdesign/verwaltung.html @@ -123,6 +123,14 @@ .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-ag-details { margin: .6rem 0 .8rem; } + .wd .vw-ag-details summary { + cursor: pointer; font-size: .86rem; color: var(--wd-text-soft); + padding: .3rem 0; min-height: 44px; display: flex; align-items: center; + } + .wd .vw-ag-details summary:hover { color: var(--wd-text); } + .wd .vw-ag-details textarea { font-family: var(--font-body); font-size: .86rem; line-height: 1.5; } + .vw-abbruch-rechnung { padding: .7rem .85rem; margin-bottom: .8rem; border-radius: var(--wd-radius-m, 14px); @@ -2288,7 +2296,50 @@ (a.kunde_id ? '

    Kunde

    ' + '

    Aus dieser Anfrage wurde bereits ein Kundenzugang erstellt. Du findest ihn oben unter „Kunden“.

    ' - : '

    Entscheiden

    ' + + : '

    Angebot schicken

    ' + + '
    ' + + /* Das Angebot steht VOR dem direkten Annehmen. + + Der übliche Weg ist: Angebot raus, Kunde sagt zu, Projekt + entsteht. Direkt anzunehmen ist der Sonderfall (man hat + schon telefoniert). Stünde der Sonderfall oben, wäre er + der naheliegende Griff — und man verschenkte den Beleg, + den eine Zusage im Portal erzeugt. */ + '

    Der Leistungsumfang kommt ' + + 'aus der Standardliste des Pakets — du wählst nur Paket und Preis. Der Kunde ' + + 'sagt im Portal mit einem Klick zu; die Anzahlung entsteht dabei von selbst.

    ' + + '
    ' + + '
    ' + + '' + + '' + + '
    ' + + '
    ' + + '' + + '' + + '…' + + '
    ' + + '
    ' + + '
    ' + + 'Leistungsumfang ansehen und ändern' + + '
    ' + + '' + + 'Kommt aus der Standardliste. Was du hier ' + + 'änderst, wird beim Absenden festgeschrieben.' + + '
    ' + + '
    ' + + '

    ' + + '' + + '
    ' + + + '

    Oder direkt entscheiden

    ' + '
    ' + "

    Annehmen — und alles läuft

    " + /* Was genau passiert, steht DA, bevor man drückt. Ein Knopf, @@ -2447,6 +2498,79 @@ return t.length === 3 ? t[2] + "." + t[1] + "." + t[0] : String(iso); } + /* ---- Angebot ---- + + Die Vorschau füllt alles vor. Wechselt man das Paket, wird neu + geladen: Preis, Laufzeit UND Leistungstext hängen daran. Ein + stehengebliebener Text vom vorigen Paket wäre der schlimmste Fall -- + er ginge unbemerkt so hinaus, und im Angebot stünde etwas, das zum + Preis nicht passt. */ + var agKasten = blatt.querySelector("#vw-angebot"); + if (agKasten) { + var agPaket = blatt.querySelector("#vw-ag-paket"); + var agPreis = blatt.querySelector("#vw-ag-preis"); + var agLeist = blatt.querySelector("#vw-ag-leistungen"); + var agGueltig = blatt.querySelector("#vw-ag-gueltig"); + var agAnz = blatt.querySelector("#vw-ag-anzahlung"); + var agGueltigBis = null; + + var agAnzahlungZeigen = function () { + var wert = Number(agPreis.value); + agAnz.textContent = Number.isFinite(wert) && wert > 0 + ? "30 % Anzahlung: " + (Math.round(wert * 30) / 100).toFixed(2).replace(".", ",") + " €" + : "Die 30 % Anzahlung wird daraus berechnet."; + }; + + var agLaden = async function (paket) { + try { + var v = await api("/webdesign/admin/anfragen/" + encodeURIComponent(a.id) + + "/angebot-vorschau" + (paket ? "?paket=" + encodeURIComponent(paket) : "")); + if (!v || !v.ok) return; + agPaket.value = v.paket; + agPreis.value = v.preisEuro; + agLeist.value = v.leistungen || ""; + agGueltigBis = v.gueltigBis; + agGueltig.textContent = "Läuft " + v.werktage + " Werktage · gültig bis " + + datumKurz(v.gueltigBis); + agAnzahlungZeigen(); + if (v.schonUnterwegs) { + agGueltig.textContent = "Achtung: " + v.schonUnterwegs.nummer + + " ist bereits unterwegs. Zieh es zurück, bevor du ein neues schickst."; + } + } catch (f) { + agGueltig.textContent = "Vorschlag konnte nicht geladen werden: " + f.message; + } + }; + agLaden(null); + agPaket.addEventListener("change", function () { agLaden(agPaket.value); }); + agPreis.addEventListener("input", agAnzahlungZeigen); + + blatt.querySelector("#vw-ag-senden").addEventListener("click", async function (e) { + var preis = Number(agPreis.value); + if (!Number.isFinite(preis) || preis <= 0) { + window.WD.meldung(meldung, "Bitte einen Preis eintragen.", "fehler"); + return; + } + window.WD.knopfSperren(e.target, true, "Wird geschickt…"); + try { + var r = await api("/webdesign/admin/anfragen/" + encodeURIComponent(a.id) + "/angebot", { + method: "POST", + body: { paket: agPaket.value, preisEuro: preis, + leistungen: agLeist.value, gueltigBis: agGueltigBis }, + }); + schliesseDetail(); + await ladeAnfragen(); + window.WD.meldung(meldung, + "Angebot " + r.nummer + " ist raus: " + r.preisText + " · Anzahlung " + + r.anzahlungText + " · gültig bis " + datumKurz(r.gueltigBis) + + ". " + (r.hinweis || ""), "ok"); + } catch (f) { + window.WD.knopfSperren(e.target, false); + window.WD.meldung(meldung, "Angebot konnte nicht raus: " + f.message, "fehler"); + } + }); + } + var annahmeKasten = blatt.querySelector("#vw-annahme"); if (annahmeKasten) { var terminFeld = blatt.querySelector("#vw-an-termin");