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