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