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
+42
View File
@@ -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;
+1 -1
View File
@@ -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. */
+125 -1
View File
@@ -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
? '<div class="vw-abschnitt"><h3>Kunde</h3>' +
'<p class="wd-klein wd-leise">Aus dieser Anfrage wurde bereits ein Kundenzugang erstellt. Du findest ihn oben unter „Kunden“.</p></div>'
: '<div class="vw-abschnitt"><h3>Entscheiden</h3>' +
: '<div class="vw-abschnitt"><h3>Angebot schicken</h3>' +
'<div class="vw-uebernahme" id="vw-angebot">' +
/* 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. */
'<p class="wd-klein wd-leise" style="margin-top:0">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.</p>' +
'<div class="wd-grid wd-grid--2" style="gap:.7rem">' +
'<div class="wd-feld">' +
'<label for="vw-ag-paket">Paket</label>' +
'<select id="vw-ag-paket">' +
'<option value="onepager">Onepager</option>' +
'<option value="website">Website</option>' +
'<option value="shop">Onlineshop</option>' +
'<option value="verwaltung">Verwaltungsbereich</option>' +
'<option value="betreuung">Laufende Betreuung</option>' +
'<option value="unklar">Noch unklar</option>' +
'</select>' +
'</div>' +
'<div class="wd-feld">' +
'<label for="vw-ag-preis">Preis in Euro</label>' +
'<input type="number" id="vw-ag-preis" min="1" step="1" inputmode="numeric" />' +
'<span class="wd-feld-hilfe" id="vw-ag-anzahlung">…</span>' +
'</div>' +
'</div>' +
'<details class="vw-ag-details">' +
'<summary>Leistungsumfang ansehen und ändern</summary>' +
'<div class="wd-feld" style="margin-top:.6rem">' +
'<textarea id="vw-ag-leistungen" rows="12"></textarea>' +
'<span class="wd-feld-hilfe">Kommt aus der Standardliste. Was du hier ' +
'änderst, wird beim Absenden festgeschrieben.</span>' +
'</div>' +
'</details>' +
'<p class="wd-klein wd-leise" id="vw-ag-gueltig"></p>' +
'<button type="button" class="wd-btn wd-btn--haupt" id="vw-ag-senden" ' +
'style="width:100%">Angebot schicken</button>' +
'</div></div>' +
'<div class="vw-abschnitt"><h3>Oder direkt entscheiden</h3>' +
'<div class="vw-uebernahme" id="vw-annahme">' +
"<h4>Annehmen — und alles läuft</h4>" +
/* 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");