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:
2026-08-23 22:44:08 +02:00
co-authored by Claude Opus 5
parent 37f1b4a8f5
commit 131bc8a755
18 changed files with 1818 additions and 89 deletions
+18
View File
@@ -41,6 +41,24 @@
.po-phase.po-jetzt { background: var(--wd-blau); box-shadow: 0 0 10px rgb(var(--wd-blau-rgb) / .6); }
.po-phasen-text { font-size: .82rem; color: var(--wd-text-soft); }
/* Frist-Marke neben dem Liefertermin.
Bewusst zurueckhaltend im Normalfall: Ein Kunde, dessen Projekt
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. */
.po-frist {
display: inline-block; margin-left: .35rem;
padding: .1rem .5rem; border-radius: 999px;
font-size: .76rem; font-weight: 700;
background: rgb(var(--wd-blau-rgb) / .14); color: var(--wd-blau);
font-variant-numeric: tabular-nums;
}
.po-frist--warn { background: rgb(233 196 106 / .16); color: #e9c46a; }
/* Gedaempftes Korallrot statt reinem Rot: auf dunklem Grund ist volles
Rot blendend und schlecht lesbar. */
.po-frist--rot { background: rgb(231 130 120 / .16); color: #e78278; }
.po-wartet {
display: inline-flex; align-items: center; gap: .35rem;
padding: .25rem .65rem; border-radius: 999px; font-size: .78rem; font-weight: 700;
+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-v22";
const CACHE_NAME = "dogfather-webdesign-v23";
/* Bausteine, die die Oberfläche zum Anzeigen braucht. Bewusst KEINE
HTML-Datei in dieser Liste. */
+224 -58
View File
@@ -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 {