Anfragen annehmen und ablehnen — mit laufender Zeit bis zur Übergabe
Annehmen war bisher nur ein Etikett: Man stellte den Status um, und es passierte nichts. Kunde, Projekt, Aufgabenliste, Termin und Zugang musste man danach von Hand in fünf Schritten nachbauen. Der alte Code gab das im Kommentar selbst zu -- schlug der zweite von zwei Aufrufen fehl, stand der Kunde schon in der Datenbank und man durfte es nicht noch einmal versuchen. Jetzt macht das EIN Aufruf, ganz oder gar nicht: Kunde finden oder anlegen, Projekt anlegen, die komplette Aufgabenliste des Pakets einsetzen, Liefertermin berechnen, Einladungslink erzeugen. Der Kunde verfolgt ab diesem Moment alles in seinem Portal. Die Zeit läuft wirklich: - Eigenes Datumsfeld (termin_am) neben dem freien Text. Aus 'Mitte Oktober' kann man keine verbleibenden Tage rechnen. - Werktage statt Kalendertage, inklusive luxemburgischer Feiertage. Die beweglichen werden über die Osterformel berechnet statt gepflegt -- eine Liste ist im übernächsten Jahr lautlos falsch. - Vorschlag je Paket (Onepager 10, Website 20, Shop 30 Werktage), überschreibbar vor dem Bestätigen. - Verbleibende Zeit wird SERVERSEITIG gerechnet. Der Browser kennt die Feiertage nicht; zwei verschiedene Zahlen für denselben Termin wären schlimmer als gar keine. Ablehnen mit Grund und vorbereiteter Absage in fünf Sprachen, Text serverseitig erzeugt und vor dem Abschicken lesbar. Ein laufendes Projekt lässt sich nicht nachträglich als Anfrage ablehnen. Sechs Fehler dabei gefunden: - Ein unbekanntes Paket hätte GAR KEINEN Termin bekommen statt des Ersatzwerts. Zwei Funktionen lasen dieselbe Tabelle, nur eine hatte einen Rückfallwert. Eine fehlende Zahl fällt nirgends auf. - wd_anfragen hat keine Spalte 'firma' -- better-sqlite3 weist undefined ab, die ganze Annahme wäre gescheitert. - Migrationen stehen in einer ausdrücklichen Liste; 0015 fehlte darin. - datumKurz() wurde aufgerufen, gab es aber nicht. Der Fehler wäre erst NACH dem Anlegen aufgetreten. - Der Installations-Hinweis lag fest über dem Annehmen-Knopf und machte ihn auf dem Handy untreffbar. Die Seite reserviert jetzt Platz dafür. - 'richttermin' ist freier Text, lief aber durch einen Datumsformatierer: Der Kunde las 'Invalid Date' an der wichtigsten Stelle seines Projekts. Geprüft: 49 Prüfungen der Terminrechnung gegen nachschlagbare Osterdaten und Wochentage, 70 gegen eine echte Datenbank (darunter: zweiter Klick legt nichts doppelt an, Abbruch mittendrin lässt NICHTS zurück, gesperrter Kunde wird nicht still entsperrt), 42 im Browser auf Computer und Handy, 40 im Portal. Alle bestehenden Prüfungen weiter grün. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+224
-58
@@ -1873,22 +1873,74 @@
|
||||
(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>Weiter geht es</h3>' +
|
||||
'<div class="vw-uebernahme">' +
|
||||
"<h4>Anfrage übernehmen</h4>" +
|
||||
"<p>Legt in einem Schritt den Kundenzugang UND das Projekt an — mit den Angaben aus dieser Anfrage. Du bekommst danach sofort den Einladungslink.</p>" +
|
||||
'<dl class="vw-uebernahme-vorschau">' +
|
||||
: '<div class="vw-abschnitt"><h3>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,
|
||||
der fünf Dinge auf einmal tut, muss sie benennen: Sonst
|
||||
traut man sich nicht, ihn zu drücken, oder man drückt ihn
|
||||
und ist überrascht. */
|
||||
"<p>Ein Klick legt an: Kundenzugang, Projekt, die komplette " +
|
||||
"Aufgabenliste für das Paket und den Liefertermin. Der Kunde " +
|
||||
"kann ab diesem Moment alles in seinem Portal verfolgen.</p>" +
|
||||
'<dl class="vw-uebernahme-vorschau" id="vw-an-vorschau">' +
|
||||
"<dt>Kunde</dt><dd>" + s(a.name) + "</dd>" +
|
||||
"<dt>E-Mail</dt><dd>" + s(a.email) + "</dd>" +
|
||||
"<dt>Projekt</dt><dd>" + s(PAKET_TEXT[a.paket] || a.paket) + " für " + s(a.name) + "</dd>" +
|
||||
"<dt>Sprache</dt><dd>" + s((a.sprache || "de").toUpperCase()) + "</dd>" +
|
||||
"<dt>E-Mail</dt><dd>" + s(a.email || "—") + "</dd>" +
|
||||
"<dt>Projekt</dt><dd id=\"vw-an-titel\">wird geladen…</dd>" +
|
||||
"<dt>Arbeitsschritte</dt><dd id=\"vw-an-schritte\">…</dd>" +
|
||||
"</dl>" +
|
||||
'<div class="wd-feld" style="margin-bottom:.8rem">' +
|
||||
'<label for="vw-ue-preis">Gesamtpreis in Euro <span class="wd-leise">optional, später änderbar</span></label>' +
|
||||
'<input type="number" id="vw-ue-preis" min="0" step="1" inputmode="numeric" placeholder="z. B. 1500" />' +
|
||||
'<span class="wd-feld-hilfe" id="vw-ue-anzahlung">Die 30 % Anzahlung wird daraus berechnet.</span>' +
|
||||
|
||||
'<div class="wd-grid wd-grid--2" style="gap:.7rem">' +
|
||||
'<div class="wd-feld">' +
|
||||
'<label for="vw-an-termin">Liefertermin</label>' +
|
||||
'<input type="date" id="vw-an-termin" />' +
|
||||
'<span class="wd-feld-hilfe" id="vw-an-termin-hilfe">wird berechnet…</span>' +
|
||||
"</div>" +
|
||||
'<div class="wd-feld">' +
|
||||
'<label for="vw-ue-preis">Gesamtpreis in Euro <span class="wd-leise">optional</span></label>' +
|
||||
'<input type="number" id="vw-ue-preis" min="0" step="1" inputmode="numeric" placeholder="z. B. 1500" />' +
|
||||
'<span class="wd-feld-hilfe" id="vw-ue-anzahlung">Die 30 % Anzahlung wird daraus berechnet.</span>' +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
|
||||
'<button type="button" class="wd-btn wd-btn--haupt" id="vw-uebernehmen" style="width:100%;margin-top:.3rem">' +
|
||||
"Annehmen und starten</button>" +
|
||||
|
||||
/* Ablehnen steht bewusst darunter und ist zurückhaltend
|
||||
gestaltet -- es ist die seltenere und die endgültigere
|
||||
Entscheidung. Ein gleich lauter Knopf daneben lädt zum
|
||||
Verwechseln ein. */
|
||||
'<div id="vw-ablehnen-bereich" style="margin-top:1rem;padding-top:.9rem;border-top:1px solid var(--wd-rand)">' +
|
||||
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="vw-ablehnen-auf">' +
|
||||
"Stattdessen ablehnen</button>" +
|
||||
'<div id="vw-ablehnen-form" hidden style="margin-top:.8rem">' +
|
||||
'<div class="wd-feld">' +
|
||||
'<label for="vw-ab-grund">Grund</label>' +
|
||||
'<select id="vw-ab-grund">' +
|
||||
'<option value="kein_zeitfenster">Kein freies Zeitfenster</option>' +
|
||||
'<option value="passt_nicht">Passt fachlich nicht</option>' +
|
||||
'<option value="budget">Budget passt nicht zum Aufwand</option>' +
|
||||
'<option value="zu_gross">Zu groß für allein</option>' +
|
||||
'<option value="kein_kontakt">Kein Kontakt zustande gekommen</option>' +
|
||||
'<option value="sonstiges">Sonstiges</option>' +
|
||||
"</select>" +
|
||||
'<span class="wd-feld-hilfe">Der Text unten wird daraus erzeugt — in der Sprache der Anfrage (' +
|
||||
s((a.sprache || "de").toUpperCase()) + ").</span>" +
|
||||
"</div>" +
|
||||
/* Der Text ist LESBAR, bevor er rausgeht. Eine Absage
|
||||
ist die letzte Nachricht, die dieser Mensch von hier
|
||||
bekommt -- sie ungelesen abzuschicken wäre unhöflich
|
||||
und im Zweifel peinlich. */
|
||||
'<div class="wd-feld">' +
|
||||
'<label for="vw-ab-text">Das bekommt der Kunde zu lesen</label>' +
|
||||
'<textarea id="vw-ab-text" readonly rows="9" style="min-height:0"></textarea>' +
|
||||
"</div>" +
|
||||
'<div style="display:flex;flex-wrap:wrap;gap:.5rem">' +
|
||||
'<button type="button" class="wd-btn wd-btn--klein" id="vw-ab-senden">Ablehnen und Absage schicken</button>' +
|
||||
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="vw-ab-still">Nur intern ablehnen</button>' +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
'<button type="button" class="wd-btn wd-btn--haupt" id="vw-uebernehmen">Kunde und Projekt anlegen</button>' +
|
||||
"</div></div>") +
|
||||
|
||||
'<div class="vw-abschnitt"><h3>Status ändern</h3>' +
|
||||
@@ -1953,14 +2005,88 @@
|
||||
}
|
||||
});
|
||||
|
||||
var preisFeld = blatt.querySelector("#vw-ue-preis");
|
||||
if (preisFeld) {
|
||||
preisFeld.addEventListener("input", function () {
|
||||
var wert = Number(preisFeld.value);
|
||||
blatt.querySelector("#vw-ue-anzahlung").textContent =
|
||||
Number.isFinite(wert) && wert > 0
|
||||
? "30 % Anzahlung: " + (Math.round(wert * 100 * 0.3) / 100).toFixed(2).replace(".", ",") + " €"
|
||||
: "Die 30 % Anzahlung wird daraus berechnet.";
|
||||
/* =================================================================
|
||||
ANNEHMEN UND ABLEHNEN
|
||||
|
||||
Vorher waren das zwei getrennte Aufrufe hintereinander: erst den
|
||||
Kunden anlegen, dann das Projekt. Der Kommentar an dieser Stelle
|
||||
gab selbst zu, was daran faul war -- schlug der zweite fehl,
|
||||
stand der Kunde schon in der Datenbank, und man durfte es nicht
|
||||
noch einmal versuchen, weil die E-Mail dann doppelt gewesen
|
||||
waere. Man musste von Hand aufraeumen.
|
||||
|
||||
Jetzt macht das EIN Aufruf, und zwar ganz oder gar nicht. Er legt
|
||||
zusaetzlich die komplette Aufgabenliste des Pakets an und
|
||||
berechnet den Liefertermin.
|
||||
================================================================= */
|
||||
/* Ein reines Datum (JJJJ-MM-TT) menschlich schreiben.
|
||||
|
||||
window.WD.datum erwartet einen Zeitstempel; ein Liefertermin ist
|
||||
aber ein Tag ohne Uhrzeit. Der erste Entwurf rief hier ein
|
||||
datumKurz() auf, das es gar nicht gab -- das haette beim ersten
|
||||
Annehmen mit Termin einen Fehler geworfen, ausgerechnet NACHDEM
|
||||
alles angelegt war. */
|
||||
function datumKurz(iso) {
|
||||
if (!iso) return "";
|
||||
var t = String(iso).slice(0, 10).split("-");
|
||||
return t.length === 3 ? t[2] + "." + t[1] + "." + t[0] : String(iso);
|
||||
}
|
||||
|
||||
var annahmeKasten = blatt.querySelector("#vw-annahme");
|
||||
if (annahmeKasten) {
|
||||
var terminFeld = blatt.querySelector("#vw-an-termin");
|
||||
var terminHilfe = blatt.querySelector("#vw-an-termin-hilfe");
|
||||
|
||||
/* Die Vorschau holen, damit die Felder gefuellt sind, BEVOR
|
||||
jemand drueckt. Schlaegt sie fehl, bleibt der Knopf trotzdem
|
||||
benutzbar -- der Server setzt dann seinen eigenen Vorschlag
|
||||
ein. Eine kaputte Vorschau darf das Annehmen nicht blockieren. */
|
||||
api("/webdesign/admin/anfragen/" + encodeURIComponent(a.id) + "/annahme-vorschau")
|
||||
.then(function (v) {
|
||||
if (!v || !v.ok) return;
|
||||
var t = blatt.querySelector("#vw-an-titel");
|
||||
if (t) t.textContent = v.titelVorschlag;
|
||||
var sch = blatt.querySelector("#vw-an-schritte");
|
||||
if (sch) sch.textContent = v.schritte + " Punkte aus der Standardliste";
|
||||
|
||||
if (v.laufend) {
|
||||
/* Eine laufende Betreuung hat kein Ende. Ein Datumsfeld
|
||||
dafuer waere eine Falle: Was man einträgt, wäre eine
|
||||
Zusage, die niemand gemeint hat. */
|
||||
terminFeld.value = "";
|
||||
terminFeld.disabled = true;
|
||||
terminHilfe.textContent = "Laufende Betreuung — kein Liefertermin.";
|
||||
} else {
|
||||
terminFeld.value = v.terminVorschlag || "";
|
||||
terminHilfe.textContent = v.werktage + " Werktage ab heute · Wochenenden und "
|
||||
+ "Feiertage sind schon abgezogen. Änderbar.";
|
||||
}
|
||||
if (!v.kundeNeu && v.kunde) {
|
||||
var kd = blatt.querySelector("#vw-an-vorschau");
|
||||
if (kd) {
|
||||
var dt = document.createElement("dt"); dt.textContent = "Hinweis";
|
||||
var dd = document.createElement("dd");
|
||||
dd.textContent = "Diesen Kunden gibt es schon — er bekommt das Projekt "
|
||||
+ "in seinen bestehenden Zugang, kein zweiter Eintrag.";
|
||||
kd.appendChild(dt); kd.appendChild(dd);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function () { terminHilfe.textContent = "Vorschlag konnte nicht geladen werden — der Server setzt einen ein."; });
|
||||
}
|
||||
|
||||
var preisFeld2 = blatt.querySelector("#vw-ue-preis");
|
||||
if (preisFeld2) {
|
||||
/* Die 30 % mitrechnen, waehrend getippt wird. Wer den Gesamtpreis
|
||||
eingibt, will wissen, was er gleich als Anzahlung stellt --
|
||||
nachrechnen im Kopf ist eine Fehlerquelle. */
|
||||
preisFeld2.addEventListener("input", function () {
|
||||
var wert = Number(preisFeld2.value);
|
||||
var ziel = blatt.querySelector("#vw-ue-anzahlung");
|
||||
if (!ziel) return;
|
||||
ziel.textContent = Number.isFinite(wert) && wert > 0
|
||||
? "30 % Anzahlung: " + (Math.round(wert * 100 * 0.3) / 100).toFixed(2).replace(".", ",") + " €"
|
||||
: "Die 30 % Anzahlung wird daraus berechnet.";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1969,51 +2095,91 @@
|
||||
ueKnopf.addEventListener("click", async function (e) {
|
||||
window.WD.knopfSperren(e.target, true, "Wird angelegt…");
|
||||
try {
|
||||
/* Zwei Schritte, bewusst nacheinander und nicht parallel: das
|
||||
Projekt braucht die Kunden-Kennung. Schlaegt der zweite
|
||||
fehl, existiert der Kunde trotzdem schon -- deshalb sagt die
|
||||
Fehlermeldung unten ausdruecklich, dass er angelegt wurde,
|
||||
damit niemand es blind noch einmal versucht und dann an der
|
||||
doppelten E-Mail scheitert. */
|
||||
var kunde = await api("/webdesign/admin/kunden", {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: a.name, email: a.email, telefon: a.telefon || "",
|
||||
sprache: a.sprache || "de", freischalten: true,
|
||||
},
|
||||
});
|
||||
|
||||
var preis = Number(preisFeld && preisFeld.value);
|
||||
try {
|
||||
await api("/webdesign/admin/projekte", {
|
||||
method: "POST",
|
||||
body: {
|
||||
kundeId: kunde.id,
|
||||
anfrageId: a.id,
|
||||
titel: (PAKET_TEXT[a.paket] || a.paket) + " für " + a.name,
|
||||
paket: a.paket === "unklar" ? "website" : a.paket,
|
||||
preisEuro: Number.isFinite(preis) && preis > 0 ? preis : undefined,
|
||||
richttermin: a.termin || "",
|
||||
naechsterSchritt: "Briefing-Termin vereinbaren",
|
||||
},
|
||||
});
|
||||
} catch (projektFehler) {
|
||||
window.WD.meldung(meldung,
|
||||
"Der Kundenzugang wurde angelegt, das Projekt aber nicht: " +
|
||||
projektFehler.message + " Du kannst das Projekt unter „Kunden“ nachtragen.", "fehler");
|
||||
}
|
||||
var preis = Number(preisFeld2 && preisFeld2.value);
|
||||
var tf = blatt.querySelector("#vw-an-termin");
|
||||
var antwort = await api(
|
||||
"/webdesign/admin/anfragen/" + encodeURIComponent(a.id) + "/annehmen",
|
||||
{ method: "POST", body: {
|
||||
termin: (tf && !tf.disabled && tf.value) ? tf.value : undefined,
|
||||
preisEuro: Number.isFinite(preis) && preis > 0 ? preis : undefined,
|
||||
} });
|
||||
|
||||
schliesseDetail();
|
||||
aktiverTab = "kunden";
|
||||
document.querySelector('[data-tab="kunden"]').click();
|
||||
zeigeLink(kunde.einladungsLink);
|
||||
await ladeAnfragen();
|
||||
|
||||
if (antwort.einladungsLink) {
|
||||
zeigeLink(antwort.einladungsLink);
|
||||
} else {
|
||||
/* Kein Link heisst: Der Kunde hatte schon einen Zugang. Das
|
||||
muss man SAGEN -- sonst sucht man den Link und glaubt,
|
||||
etwas sei schiefgegangen. */
|
||||
window.WD.meldung(meldung,
|
||||
"Angenommen. " + antwort.projektNummer + " ist angelegt, "
|
||||
+ antwort.schritte + " Arbeitsschritte stehen bereit"
|
||||
+ (antwort.termin ? ", Liefertermin " + datumKurz(antwort.termin) : "")
|
||||
+ ". " + antwort.hinweis, "ok");
|
||||
}
|
||||
} catch (fehler) {
|
||||
window.WD.meldung(meldung,
|
||||
"Annehmen fehlgeschlagen: " + fehler.message
|
||||
+ " Es wurde nichts angelegt — du kannst es gefahrlos noch einmal versuchen.", "fehler");
|
||||
} finally {
|
||||
window.WD.knopfSperren(e.target, false);
|
||||
window.WD.meldung(meldung, fehler.message, "fehler");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- Ablehnen ---- */
|
||||
var abAuf = blatt.querySelector("#vw-ablehnen-auf");
|
||||
if (abAuf) {
|
||||
var abForm = blatt.querySelector("#vw-ablehnen-form");
|
||||
var abGrund = blatt.querySelector("#vw-ab-grund");
|
||||
var abText = blatt.querySelector("#vw-ab-text");
|
||||
|
||||
async function absageHolen() {
|
||||
abText.value = "wird erzeugt…";
|
||||
try {
|
||||
var v = await api("/webdesign/admin/anfragen/" + encodeURIComponent(a.id)
|
||||
+ "/absage-vorschau?grund=" + encodeURIComponent(abGrund.value));
|
||||
abText.value = v.text || "";
|
||||
} catch (f) {
|
||||
abText.value = "Der Text konnte nicht geladen werden: " + f.message;
|
||||
}
|
||||
}
|
||||
|
||||
abAuf.addEventListener("click", function () {
|
||||
abForm.hidden = !abForm.hidden;
|
||||
abAuf.textContent = abForm.hidden ? "Stattdessen ablehnen" : "Doch nicht ablehnen";
|
||||
if (!abForm.hidden) absageHolen();
|
||||
});
|
||||
abGrund.addEventListener("change", absageHolen);
|
||||
|
||||
async function ablehnen(senden, knopf) {
|
||||
window.WD.knopfSperren(knopf, true, "Wird gespeichert…");
|
||||
try {
|
||||
var r = await api("/webdesign/admin/anfragen/" + encodeURIComponent(a.id) + "/ablehnen",
|
||||
{ method: "POST", body: { grund: abGrund.value, senden: senden } });
|
||||
schliesseDetail();
|
||||
await ladeAnfragen();
|
||||
window.WD.meldung(meldung,
|
||||
senden
|
||||
? (r.imPostfach
|
||||
? "Abgelehnt. Die Absage liegt im Postfach des Kunden."
|
||||
: "Abgelehnt. " + (r.hinweis || ""))
|
||||
: "Abgelehnt — nur intern vermerkt, es ging nichts hinaus.",
|
||||
"ok");
|
||||
} catch (fehler) {
|
||||
window.WD.meldung(meldung, "Ablehnen fehlgeschlagen: " + fehler.message, "fehler");
|
||||
} finally {
|
||||
window.WD.knopfSperren(knopf, false);
|
||||
}
|
||||
}
|
||||
blatt.querySelector("#vw-ab-senden")
|
||||
.addEventListener("click", function (e) { ablehnen(true, e.target); });
|
||||
blatt.querySelector("#vw-ab-still")
|
||||
.addEventListener("click", function (e) { ablehnen(false, e.target); });
|
||||
}
|
||||
|
||||
blatt.querySelector("#vw-archivieren").addEventListener("click", async function (e) {
|
||||
window.WD.knopfSperren(e.target, true, "…");
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user