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 <[email protected]>
This commit is contained in:
2026-08-24 12:23:44 +02:00
co-authored by Claude Opus 5
parent cea265eb8c
commit 8726110035
6 changed files with 573 additions and 4 deletions
+37
View File
@@ -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" },
+127 -2
View File
@@ -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 '<h4 class="po-ag-gruppe">' + s(fett[1]) + "</h4>";
if (t.indexOf("\u00b7") === 0) return '<li>' + s(t.slice(1).trim()) + "</li>";
return "<p>" + s(t) + "</p>";
}).join("").replace(/(<li>.*?<\/li>)+/g, function (m) {
return '<ul class="po-ag-liste">' + m + "</ul>";
});
}
function angebotsKarten(angebote) {
if (!angebote || !angebote.length) return "";
return angebote.map(function (a) {
var offen = a.status === "gesendet";
return '<div class="wd-karte po-angebot' + (offen ? " po-angebot--offen" : "") +
'" data-angebot="' + s(a.id) + '">' +
'<span class="po-ag-marke">' +
(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"))) + "</span>" +
"<h3>" + s(a.titel) + "</h3>" +
'<p class="po-ag-preis">' + s(a.preis_text) + "</p>" +
'<p class="wd-klein wd-leise">' + s(window.WD.t("po_ag_anzahlung"))
.replace("{betrag}", s(a.anzahlung_text)) + "</p>" +
(a.laufzeit_werktage
? '<p class="wd-klein wd-leise">' + s(window.WD.t("po_ag_dauer"))
.replace("{n}", a.laufzeit_werktage) + "</p>"
: "") +
'<div class="po-ag-leistungen">' + leistungenZeichnen(a.leistungen_text) + "</div>" +
(a.hinweis_text ? '<p class="wd-klein">' + s(a.hinweis_text) + "</p>" : "") +
(offen && a.annehmbar
? '<p class="wd-klein wd-leise">' + s(window.WD.t("po_ag_gueltig"))
.replace("{datum}", s(a.gueltig_bis || "")) + "</p>" +
'<div class="po-ag-knoepfe">' +
'<button type="button" class="wd-btn wd-btn--haupt" data-zusagen="' + s(a.id) + '">' +
s(window.WD.t("po_ag_zusagen")) + "</button>" +
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" data-ablehnen="' +
s(a.id) + '">' + s(window.WD.t("po_ag_ablehnen")) + "</button>" +
"</div>"
: a.abgelaufen
? '<p class="wd-klein">' + s(window.WD.t("po_ag_abgelaufen_text")) + "</p>"
: "") +
"</div>";
}).join("");
}
function wartetMarke(wartet) {
if (wartet !== "kunde" && wartet !== "dogfather") return "";
return '<span class="po-wartet po-wartet--' + wartet + '">' +
@@ -359,6 +423,15 @@
}
html += "<h2>" + s(window.WD.t("po_projekte")) + "</h2>";
/* 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 @@
'<div class="wd-karte">' +
'<p class="wd-klein wd-leise" style="margin-top:0">' + s(window.WD.t("po_postfach_text")) + "</p>";
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 += '<p class="wd-leise wd-klein">' + s(window.WD.t("po_postfach_leer")) + "</p>";
} else {
html += '<div class="po-pf-verlauf" id="po-pf-verlauf">' + d.nachrichten.map(function (n) {
html += '<div class="po-pf-verlauf" id="po-pf-verlauf">' + nachrichten.map(function (n) {
var vonMir = n.autor === "kunde";
return '<div class="po-nachricht po-nachricht--' + (vonMir ? "ich" : "du") + '">' +
'<div class="po-nachricht-kopf">' +
@@ -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.");
}
});
})();