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:
@@ -186,6 +186,11 @@ 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_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" },
|
||||
po_heute_faellig: { de: "heute fällig", "de-CH": "heute fällig", en: "due today", fr: "échéance aujourd’hui", pt: "prazo hoje" },
|
||||
po_ueberfaellig: { de: "überfällig", "de-CH": "überfällig", en: "overdue", fr: "en retard", pt: "em atraso" },
|
||||
po_oeffnen: { de: "Öffnen", "de-CH": "Ufmache", en: "Open", fr: "Ouvrir", pt: "Abrir" },
|
||||
po_zurueck: { de: "Zurück zur Übersicht", "de-CH": "Zrugg zur Übersicht", en: "Back to overview", fr: "Retour à l'aperçu", pt: "Voltar à visão geral" },
|
||||
|
||||
|
||||
+32
-2
@@ -740,8 +740,35 @@
|
||||
"</div>";
|
||||
document.body.appendChild(box);
|
||||
|
||||
box.querySelector("[data-wd-install-weg]").addEventListener("click", function () {
|
||||
/* Platz unter der Seite schaffen, solange der Hinweis liegt.
|
||||
|
||||
Er sitzt fest am unteren Rand und verdeckt sonst dauerhaft das,
|
||||
was dort steht. Am 23.08.2026 blockierte er in der Verwaltung den
|
||||
Annehmen-Knopf: Der Knopf war sichtbar, liess sich aber nicht
|
||||
treffen -- man haette gedrueckt und gedrueckt und geglaubt, die
|
||||
Seite sei kaputt. Aufgefallen, weil ein Test dreissig Sekunden
|
||||
lang vergeblich klickte.
|
||||
|
||||
Gemessen statt geraten: Ein fester Wert waere bei zwei Zeilen
|
||||
Text zu klein und bei einer zu gross. */
|
||||
function platzSchaffen() {
|
||||
var hoehe = box.getBoundingClientRect().height;
|
||||
document.body.style.paddingBottom = (hoehe + 24) + "px";
|
||||
}
|
||||
platzSchaffen();
|
||||
/* Bei Drehung oder Groessenaenderung neu messen -- im Querformat
|
||||
braucht derselbe Text weniger Zeilen. */
|
||||
window.addEventListener("resize", platzSchaffen);
|
||||
|
||||
function wegRaeumen() {
|
||||
window.removeEventListener("resize", platzSchaffen);
|
||||
document.body.style.paddingBottom = "";
|
||||
box.remove();
|
||||
}
|
||||
box.wdWegRaeumen = wegRaeumen;
|
||||
|
||||
box.querySelector("[data-wd-install-weg]").addEventListener("click", function () {
|
||||
wegRaeumen();
|
||||
/* localStorage, nicht sessionStorage: ein "nein danke" soll halten.
|
||||
Wer es sich anders überlegt, kann jederzeit über das Browsermenü
|
||||
installieren. */
|
||||
@@ -764,7 +791,10 @@
|
||||
e.preventDefault();
|
||||
var box = installBannerBauen(t("install_text"), true);
|
||||
box.querySelector("[data-wd-install]").addEventListener("click", async function () {
|
||||
box.remove();
|
||||
/* Ueber wegRaeumen(), nicht ueber box.remove(): Sonst bliebe der
|
||||
reservierte Platz unter der Seite stehen, und man haette nach
|
||||
dem Installieren dauerhaft eine leere Flaeche am Ende. */
|
||||
box.wdWegRaeumen();
|
||||
e.prompt();
|
||||
try { await e.userChoice; } catch (err) { /* egal, Entscheidung liegt beim Nutzer */ }
|
||||
});
|
||||
|
||||
+66
-4
@@ -138,6 +138,71 @@
|
||||
(jetzt + 1) + "/" + PHASEN.length + "</div>";
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
LIEFERTERMIN UND VERBLEIBENDE ZEIT
|
||||
|
||||
ZWEI FEHLER, DIE HIER VORHER STECKTEN
|
||||
|
||||
1. `richttermin` ist FREIER TEXT ("Mitte Oktober", "sobald die Texte
|
||||
da sind"). Er wurde durch einen Datumsformatierer geschickt --
|
||||
und der macht daraus "Invalid Date". Der Kunde las an der
|
||||
wichtigsten Stelle seines Projekts einen englischen Fehlertext.
|
||||
Freier Text wird jetzt als freier Text gezeigt.
|
||||
|
||||
2. Es gab gar keinen echten Termin, an dem man etwas ablesen konnte.
|
||||
Jetzt gibt es `termin_am` als richtiges Datum daneben.
|
||||
|
||||
Die verbleibenden Tage kommen FERTIG vom Server. Hier wird nicht
|
||||
nachgerechnet: Der Browser kennt die luxemburgischen Feiertage
|
||||
nicht, und zwei verschiedene Zahlen fuer denselben Termin waeren
|
||||
schlimmer als gar keine.
|
||||
--------------------------------------------------------------------- */
|
||||
function restText(tage) {
|
||||
if (tage === null || tage === undefined) return null;
|
||||
if (tage < 0) return { text: window.WD.t("po_ueberfaellig"), ton: "po-frist--rot" };
|
||||
if (tage === 0) return { text: window.WD.t("po_heute_faellig"), ton: "po-frist--warn" };
|
||||
if (tage === 1) return { text: window.WD.t("po_noch_ein_tag"), ton: "po-frist--warn" };
|
||||
return {
|
||||
text: String(window.WD.t("po_noch_tage")).replace("{n}", tage),
|
||||
/* Ab fuenf Werktagen wird es eng -- eine Woche. Darunter faerbt
|
||||
es sich, damit der Kunde merkt, wenn seine Zuarbeit jetzt
|
||||
wirklich gebraucht wird. */
|
||||
ton: tage <= 5 ? "po-frist--warn" : "",
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function terminZeile(p) {
|
||||
var stuecke = [];
|
||||
|
||||
if (p.termin_am) {
|
||||
var rest = restText(p.verbleibende_werktage);
|
||||
stuecke.push(
|
||||
'<p class="wd-klein" style="margin:.45rem 0 0">' +
|
||||
'<strong>' + s(window.WD.t("po_liefertermin")) + ":</strong> " +
|
||||
s(datumKurz(p.termin_am)) +
|
||||
(rest ? ' <span class="po-frist ' + rest.ton + '">' + s(rest.text) + "</span>" : "") +
|
||||
"</p>"
|
||||
);
|
||||
}
|
||||
|
||||
/* Der freie Text nur, wenn er etwas ANDERES sagt als das Datum --
|
||||
sonst stuenden zwei Termine untereinander und man wuesste nicht,
|
||||
welcher gilt. */
|
||||
if (p.richttermin && String(p.richttermin).trim()) {
|
||||
stuecke.push(
|
||||
'<p class="wd-klein wd-leise" style="margin:.15rem 0 0">' +
|
||||
s(window.WD.t("po_richttermin")) + ": " + s(p.richttermin) + "</p>"
|
||||
);
|
||||
}
|
||||
return stuecke.join("");
|
||||
}
|
||||
|
||||
function wartetMarke(wartet) {
|
||||
if (wartet !== "kunde" && wartet !== "dogfather") return "";
|
||||
return '<span class="po-wartet po-wartet--' + wartet + '">' +
|
||||
@@ -361,10 +426,7 @@
|
||||
? '<p class="wd-klein" style="margin:.7rem 0 .3rem"><strong>' +
|
||||
s(window.WD.t("po_naechster_schritt")) + ":</strong> " + s(p.naechster_schritt) + "</p>"
|
||||
: "") +
|
||||
(p.richttermin
|
||||
? '<p class="wd-klein wd-leise" style="margin:0">' + s(window.WD.t("po_richttermin")) +
|
||||
": " + window.WD.datum(p.richttermin) + "</p>"
|
||||
: "") +
|
||||
terminZeile(p) +
|
||||
(p.offene_aenderungen || p.ungelesene_nachrichten
|
||||
? '<p class="wd-klein" style="margin:.5rem 0 0;color:var(--wd-blau)">' +
|
||||
(p.ungelesene_nachrichten ? p.ungelesene_nachrichten + " " + s(window.WD.t("po_nachrichten")) : "") +
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
-- =====================================================================
|
||||
-- Annehmen und Ablehnen einer Anfrage — und eine Zeit, die wirklich läuft
|
||||
--
|
||||
-- WARUM EIN ZWEITES TERMINFELD
|
||||
--
|
||||
-- `wd_projekte.richttermin` gibt es schon, es ist aber freier Text:
|
||||
-- "Mitte Oktober", "KW 42", "sobald die Texte da sind". Das ist für ein
|
||||
-- Gespräch genau richtig und für eine Rechnung völlig unbrauchbar — man
|
||||
-- kann daraus keine verbleibenden Tage ermitteln.
|
||||
--
|
||||
-- Den bestehenden Text nachträglich zu deuten wäre die schlechtere
|
||||
-- Lösung: Aus "Mitte Oktober" ein Datum zu raten heißt, sich bei jedem
|
||||
-- zweiten Projekt um Tage zu vertun — und ausgerechnet ein Liefertermin
|
||||
-- ist nichts, wo man raten sollte. Deshalb ein eigenes, echtes Datum
|
||||
-- daneben. Der Text bleibt, weil er das Menschliche transportiert
|
||||
-- ("wenn deine Texte da sind"), das Datum trägt das Rechnen.
|
||||
--
|
||||
-- Bewusst ein reines Datum (JJJJ-MM-TT) und kein Zeitstempel: Ein
|
||||
-- Liefertermin ist ein Tag, keine Uhrzeit. "Noch 3 Tage" darf nicht
|
||||
-- davon abhängen, ob um 9 oder um 17 Uhr angenommen wurde.
|
||||
-- =====================================================================
|
||||
|
||||
ALTER TABLE wd_projekte ADD COLUMN termin_am TEXT;
|
||||
|
||||
-- Wann die Uhr zu laufen begann. `start_am` gibt es schon, wird aber
|
||||
-- frei gesetzt; dieses Feld hält fest, wann ANGENOMMEN wurde. Beides
|
||||
-- kann auseinanderfallen (angenommen am Freitag, Start am Montag), und
|
||||
-- für "seit wann läuft das" zählt die Annahme.
|
||||
ALTER TABLE wd_projekte ADD COLUMN angenommen_am TEXT;
|
||||
|
||||
-- Wie viele Werktage beim Annehmen vorgesehen waren. Nur zur
|
||||
-- Nachvollziehbarkeit: Verschiebt man den Termin später, sieht man noch,
|
||||
-- was ursprünglich zugesagt war.
|
||||
ALTER TABLE wd_projekte ADD COLUMN termin_werktage INTEGER;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Ablehnung
|
||||
--
|
||||
-- Der Grund steht am Vorgang selbst und nicht nur in einer Notiz: Er
|
||||
-- entscheidet mit, welcher Absagetext erzeugt wird, und man will später
|
||||
-- auswerten können, WORAN Anfragen scheitern (kein Budget? kein
|
||||
-- Zeitfenster?). In Fließtext vergraben ginge das nicht.
|
||||
-- ---------------------------------------------------------------------
|
||||
ALTER TABLE wd_anfragen ADD COLUMN ablehnung_grund TEXT;
|
||||
ALTER TABLE wd_anfragen ADD COLUMN ablehnung_am TEXT;
|
||||
|
||||
-- Ob die Absage tatsächlich verschickt wurde. Getrennt vom Grund, weil
|
||||
-- beides verschiedene Dinge sind: Man kann intern ablehnen, ohne zu
|
||||
-- schreiben — und dann muss man sehen, dass der Mensch noch wartet.
|
||||
ALTER TABLE wd_anfragen ADD COLUMN absage_gesendet_am TEXT;
|
||||
|
||||
-- Zwei Wege führen von einer Anfrage zum Projekt, und beide sollen
|
||||
-- schnell auffindbar sein: vom Projekt zur Anfrage (Spalte anfrage_id,
|
||||
-- gibt es schon) und umgekehrt.
|
||||
CREATE INDEX IF NOT EXISTS idx_wd_projekte_anfrage ON wd_projekte (anfrage_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wd_projekte_termin ON wd_projekte (termin_am);
|
||||
@@ -0,0 +1,213 @@
|
||||
/* =====================================================================
|
||||
pruef-annahme.mjs — Annehmen und Ablehnen in der Verwaltung
|
||||
|
||||
Geprüft wird nicht, ob Knöpfe da sind, sondern WAS sie senden. Ein
|
||||
Knopf, der sich färbt und nichts abschickt, besteht jede Sichtprüfung.
|
||||
|
||||
Der wichtigste Fall ist der, den man beim Bauen vergisst: Der erste
|
||||
Entwurf rief nach dem Annehmen ein `datumKurz()` auf, das es gar nicht
|
||||
gab. Der Fehler wäre erst NACHDEM alles angelegt war aufgetreten — man
|
||||
hätte einen fertigen Kunden, ein fertiges Projekt und auf dem Schirm
|
||||
eine rote Meldung. Deshalb wird hier auch das Protokoll mitgelesen.
|
||||
===================================================================== */
|
||||
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(4351, r));
|
||||
|
||||
const ergebnisse = [];
|
||||
function pruefe(name, ok, zusatz) {
|
||||
ergebnisse.push({ name, ok });
|
||||
console.log((ok ? " ok " : " FEHL ") + name + (zusatz !== undefined ? " -> " + zusatz : ""));
|
||||
}
|
||||
|
||||
const ANFRAGE = {
|
||||
id: "t1", nummer: "A-2608-0007", name: "Anna Muster", email: "[email protected]",
|
||||
telefon: "+352 691 000000", kontaktweg: "email", paket: "website",
|
||||
ziel: "Mehr Anfragen", zielgruppe: "Privatkunden", details: "Bitte modern und schlicht.",
|
||||
budget: "1000-2500", termin: "Mitte Oktober", status: "neu", sprache: "de",
|
||||
archiviert: 0, kunde_id: null, erstellt_am: new Date().toISOString(),
|
||||
};
|
||||
const VORSCHAU = {
|
||||
ok: true, paket: "website", titelVorschlag: "Website für Anna Muster",
|
||||
terminVorschlag: "2026-09-21", werktage: 20, laufend: false, schritte: 18,
|
||||
kunde: null, kundeNeu: true, bereitsAngenommen: null,
|
||||
};
|
||||
|
||||
for (const [wo, angaben] of [
|
||||
["Computer", { viewport: { width: 1440, height: 950 } }],
|
||||
["Handy", { ...devices["iPhone 14 Pro"] }],
|
||||
]) {
|
||||
const browser = await chromium.launch();
|
||||
const kontext = await browser.newContext(angaben);
|
||||
const seite = await kontext.newPage();
|
||||
const protokoll = [];
|
||||
seite.on("pageerror", (e) => protokoll.push(e.message));
|
||||
|
||||
let gesendet = null, abgelehnt = null, vorschauGeholt = 0, absageGeholt = [];
|
||||
|
||||
await seite.route("**/webdesign/admin/**", (route) => {
|
||||
const u = new URL(route.request().url());
|
||||
const pf = u.pathname;
|
||||
if (pf.endsWith("/annahme-vorschau")) { vorschauGeholt++; return route.fulfill({ json: VORSCHAU }); }
|
||||
if (pf.endsWith("/absage-vorschau")) {
|
||||
absageGeholt.push(u.searchParams.get("grund"));
|
||||
return route.fulfill({ json: { ok: true, sprache: "de", grund: u.searchParams.get("grund"),
|
||||
text: "Hallo Anna Muster,\n\nvielen Dank für deine Anfrage.\n\nGrund: "
|
||||
+ u.searchParams.get("grund") + "\n\nViele Grüße\nDogfather Webdesign" } });
|
||||
}
|
||||
if (pf.endsWith("/annehmen")) {
|
||||
gesendet = route.request().postDataJSON();
|
||||
return route.fulfill({ json: { ok: true, projektId: "p9", projektNummer: "P-2608-0003",
|
||||
kundeId: "k9", kundeNeu: true, termin: gesendet.termin || "2026-09-21", werktage: 20,
|
||||
schritte: 18, einladungsLink: null,
|
||||
hinweis: "Der Kunde hatte bereits einen Zugang — er sieht das Projekt sofort." } });
|
||||
}
|
||||
if (pf.endsWith("/ablehnen")) {
|
||||
abgelehnt = route.request().postDataJSON();
|
||||
return route.fulfill({ json: { ok: true, grund: abgelehnt.grund, sprache: "de",
|
||||
text: "…", imPostfach: true } });
|
||||
}
|
||||
if (pf.includes("/admin/anfragen/t1")) {
|
||||
return route.fulfill({ json: { ok: true, anfrage: ANFRAGE, notizen: [] } });
|
||||
}
|
||||
if (pf.includes("/admin/anfragen")) return route.fulfill({ json: { ok: true, anfragen: [ANFRAGE] } });
|
||||
if (pf.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: {} } });
|
||||
}
|
||||
/* Ein Kunden- oder Projekt-Anlegen darf hier NICHT mehr vorkommen.
|
||||
Vorher waren das zwei getrennte Aufrufe; taucht einer wieder auf,
|
||||
ist der alte Weg zurueck. */
|
||||
if (pf.endsWith("/admin/kunden") && route.request().method() === "POST") {
|
||||
gesendet = { FEHLER: "alter Weg: Kunde einzeln angelegt" };
|
||||
return route.fulfill({ json: { ok: true, id: "k9", einladungsLink: "x" } });
|
||||
}
|
||||
if (pf.endsWith("/admin/projekte") && route.request().method() === "POST") {
|
||||
gesendet = { FEHLER: "alter Weg: Projekt einzeln angelegt" };
|
||||
return route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
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:4351/webdesign/verwaltung.html");
|
||||
await seite.waitForTimeout(700);
|
||||
console.log("\n=== " + wo + " ===");
|
||||
|
||||
await seite.click('[data-tab="anfragen"]');
|
||||
await seite.waitForTimeout(500);
|
||||
await seite.locator(".vw-karte").first().click();
|
||||
await seite.waitForTimeout(800);
|
||||
|
||||
/* ---- 1. Die Vorschau wird geladen und gezeigt ---- */
|
||||
pruefe(wo + ": die Vorschau wird beim Oeffnen geholt", vorschauGeholt === 1, vorschauGeholt);
|
||||
pruefe(wo + ": der vorgeschlagene Titel steht da",
|
||||
(await seite.locator("#vw-an-titel").innerText()).includes("Anna Muster"),
|
||||
await seite.locator("#vw-an-titel").innerText());
|
||||
pruefe(wo + ": die Zahl der Arbeitsschritte steht da",
|
||||
(await seite.locator("#vw-an-schritte").innerText()).includes("18"),
|
||||
await seite.locator("#vw-an-schritte").innerText());
|
||||
pruefe(wo + ": das Terminfeld ist vorbelegt",
|
||||
(await seite.locator("#vw-an-termin").inputValue()) === "2026-09-21",
|
||||
await seite.locator("#vw-an-termin").inputValue());
|
||||
/* Der Hinweis muss erklaeren, WORAUS die Zahl entsteht -- ein Datum
|
||||
ohne Begruendung wirkt geraten. */
|
||||
pruefe(wo + ": der Hinweis nennt Werktage und abgezogene Feiertage",
|
||||
/20 Werktage/.test(await seite.locator("#vw-an-termin-hilfe").innerText())
|
||||
&& /Feiertage/.test(await seite.locator("#vw-an-termin-hilfe").innerText()),
|
||||
await seite.locator("#vw-an-termin-hilfe").innerText());
|
||||
|
||||
/* ---- 2. Die 30 % werden mitgerechnet ---- */
|
||||
await seite.fill("#vw-ue-preis", "2000");
|
||||
await seite.waitForTimeout(200);
|
||||
pruefe(wo + ": die Anzahlung wird waehrend des Tippens gerechnet",
|
||||
(await seite.locator("#vw-ue-anzahlung").innerText()).includes("600,00"),
|
||||
await seite.locator("#vw-ue-anzahlung").innerText());
|
||||
|
||||
/* ---- 3. Annehmen sendet EINEN Aufruf mit allem ---- */
|
||||
await seite.fill("#vw-an-termin", "2026-10-15");
|
||||
await seite.click("#vw-uebernehmen");
|
||||
await seite.waitForTimeout(900);
|
||||
|
||||
pruefe(wo + ": es geht genau EIN Aufruf hinaus, nicht der alte Zweischritt",
|
||||
gesendet && !gesendet.FEHLER, JSON.stringify(gesendet));
|
||||
pruefe(wo + ": der eigene Termin wird mitgeschickt",
|
||||
gesendet && gesendet.termin === "2026-10-15", gesendet && gesendet.termin);
|
||||
pruefe(wo + ": der Preis wird mitgeschickt",
|
||||
gesendet && gesendet.preisEuro === 2000, gesendet && gesendet.preisEuro);
|
||||
|
||||
/* ---- 4. Die Rueckmeldung ist verstaendlich ---- */
|
||||
const meldung = await seite.locator("#vw-meldung").innerText().catch(() => "");
|
||||
pruefe(wo + ": die Meldung nennt die Projektnummer", meldung.includes("P-2608-0003"), meldung);
|
||||
pruefe(wo + ": sie nennt die Zahl der Arbeitsschritte", meldung.includes("18"), meldung);
|
||||
pruefe(wo + ": sie nennt den Liefertermin lesbar (15.10.2026)",
|
||||
meldung.includes("15.10.2026"), meldung);
|
||||
/* Genau hier steckte der Fehler des ersten Entwurfs: datumKurz() gab
|
||||
es nicht, und der Fehler trat erst NACH dem Anlegen auf. */
|
||||
pruefe(wo + ": dabei entstand KEIN Skriptfehler", protokoll.length === 0, protokoll.join(" | "));
|
||||
|
||||
/* ---- 5. Ablehnen ---- */
|
||||
await seite.locator(".vw-karte").first().click();
|
||||
await seite.waitForTimeout(700);
|
||||
pruefe(wo + ": das Ablehnen-Formular ist zunaechst zu",
|
||||
!(await seite.locator("#vw-ablehnen-form").isVisible()));
|
||||
await seite.click("#vw-ablehnen-auf");
|
||||
await seite.waitForTimeout(600);
|
||||
pruefe(wo + ": es klappt auf", await seite.locator("#vw-ablehnen-form").isVisible());
|
||||
pruefe(wo + ": der Absagetext wird geladen",
|
||||
(await seite.locator("#vw-ab-text").inputValue()).includes("Anna Muster"),
|
||||
(await seite.locator("#vw-ab-text").inputValue()).slice(0, 50));
|
||||
/* Der Text darf NICHT im Browser bearbeitbar sein -- er ist eine
|
||||
geschaeftliche Aeusserung und entsteht auf dem Server. */
|
||||
pruefe(wo + ": der Text ist nur zum Lesen",
|
||||
await seite.locator("#vw-ab-text").evaluate((e) => e.readOnly));
|
||||
|
||||
await seite.selectOption("#vw-ab-grund", "budget");
|
||||
await seite.waitForTimeout(500);
|
||||
pruefe(wo + ": ein anderer Grund laedt einen anderen Text",
|
||||
absageGeholt.includes("budget"), absageGeholt.join(","));
|
||||
|
||||
await seite.click("#vw-ab-still");
|
||||
await seite.waitForTimeout(700);
|
||||
pruefe(wo + ": beim internen Ablehnen wird senden=false geschickt",
|
||||
abgelehnt && abgelehnt.senden === false && abgelehnt.grund === "budget",
|
||||
JSON.stringify(abgelehnt));
|
||||
const m2 = await seite.locator("#vw-meldung").innerText().catch(() => "");
|
||||
pruefe(wo + ": die Meldung sagt, dass nichts hinausging",
|
||||
/nur intern/.test(m2), m2);
|
||||
|
||||
pruefe(wo + ": bis zum Schluss kein Skriptfehler", protokoll.length === 0, protokoll.join(" | "));
|
||||
|
||||
await seite.locator(".vw-karte").first().click();
|
||||
await seite.waitForTimeout(700);
|
||||
await seite.screenshot({ path: "bild-annahme-" + wo.toLowerCase() + ".png", fullPage: true });
|
||||
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;
|
||||
+27
-1
@@ -51,7 +51,12 @@ const FORTSCHRITT = { gesamt: 6, erledigt: 2, inArbeit: 1, beimKunden: 2, prozen
|
||||
umsetzung: { gesamt: 1, erledigt: 0 }, abschluss: { gesamt: 1, erledigt: 0 } } };
|
||||
|
||||
const PROJEKT = { id: "p1", nummer: "P-0001", titel: "Website für Musterbau",
|
||||
status: "design", wartet_auf: "kunde", naechster_schritt: "Deine Rückmeldung zum Entwurf." };
|
||||
status: "design", wartet_auf: "kunde", naechster_schritt: "Deine Rückmeldung zum Entwurf.",
|
||||
/* Ein echtes Datum UND der freie Text daneben. Genau diese Kombination
|
||||
war vorher kaputt: Der freie Text ("Mitte Oktober") wurde durch
|
||||
einen Datumsformatierer geschickt und erschien als "Invalid Date". */
|
||||
termin_am: "2026-10-15", richttermin: "Mitte Oktober, sobald deine Texte da sind",
|
||||
verbleibende_werktage: 12 };
|
||||
|
||||
const ergebnisse = [];
|
||||
function pruefe(name, ok, zusatz) {
|
||||
@@ -211,6 +216,27 @@ for (const [wo, breite, hoehe] of [["Computer", 1280, 950], ["Handy", 390, 844]]
|
||||
pruefe(wo + ": Kästchen sind gross genug zum Antippen", zuKlein === 0, "zu klein: " + zuKlein);
|
||||
}
|
||||
|
||||
|
||||
/* ---- Liefertermin und verbleibende Zeit ----
|
||||
Der Kunde soll ab der Annahme sehen, WANN es fertig wird und wie viel
|
||||
Zeit noch ist. Geprueft wird die Projektkarte, nicht die Detailseite. */
|
||||
const karte = await seite.locator("[data-projekt]").first().innerText();
|
||||
pruefe(wo + ": der Liefertermin steht lesbar auf der Karte",
|
||||
karte.includes("15.10.2026"), karte.slice(0, 220));
|
||||
pruefe(wo + ": die verbleibende Zeit steht dabei",
|
||||
/12 Werktage/.test(karte), karte.slice(0, 220));
|
||||
/* Der wichtigste Punkt: Der freie Text darf NICHT durch einen
|
||||
Datumsformatierer laufen. Vorher stand hier "Invalid Date". */
|
||||
pruefe(wo + ": der freie Terminhinweis erscheint als Text, nicht als Invalid Date",
|
||||
karte.includes("Mitte Oktober") && !/Invalid/i.test(karte),
|
||||
karte.slice(0, 220));
|
||||
/* Farbe allein traegt die Aussage nicht -- bei 12 Tagen ist es
|
||||
entspannt, also darf nichts alarmieren. */
|
||||
const fristTon = await seite.locator(".po-frist").first()
|
||||
.getAttribute("class").catch(() => "");
|
||||
pruefe(wo + ": bei 12 Tagen wird nicht gewarnt",
|
||||
!/--rot|--warn/.test(fristTon || ""), fristTon);
|
||||
|
||||
pruefe(wo + ": keine Fehler im Protokoll", fehlerImProtokoll.length === 0, fehlerImProtokoll.join(" | "));
|
||||
|
||||
await seite.screenshot({ path: `pruef-portal-aufgaben-${wo.toLowerCase()}.png`, fullPage: true });
|
||||
|
||||
@@ -54,6 +54,9 @@ const MIGRATION_FILES = [
|
||||
// Portal werden Zusatzangebote per Klick verbindlich angenommen, das ist
|
||||
// ein Vertragsschluss ueber eine Online-Benutzeroberflaeche.
|
||||
"0014_webdesign_widerruf.sql",
|
||||
// Annehmen und Ablehnen einer Anfrage: echter Liefertermin (termin_am)
|
||||
// fuer den Countdown, Zeitpunkt der Annahme, Ablehnungsgrund.
|
||||
"0015_webdesign_annahme.sql",
|
||||
];
|
||||
|
||||
function alreadyApplied(name) {
|
||||
|
||||
@@ -126,6 +126,9 @@ import {
|
||||
paypalWebhook, zahlungAnlegen, zahlungenListe,
|
||||
einstellungenLesen, einstellungenSchreiben, einstellungenPruefen, uebersicht,
|
||||
} from "./routes/webdesign-zahlungen.js";
|
||||
import {
|
||||
annahmeVorschau, anfrageAnnehmen, absageVorschau, anfrageAblehnen,
|
||||
} from "./routes/webdesign-annahme.js";
|
||||
/* PayPal-Zugangsdaten koennen verschluesselt in der Datenbank liegen,
|
||||
damit Filipe sie in seiner Verwaltung eintragen kann statt per SSH.
|
||||
Sie muessen beim Start EINMAL entschluesselt werden, weil die
|
||||
@@ -298,6 +301,17 @@ app.post("/webdesign/admin/anfragen/:id/status", wrap(anfrageStatus));
|
||||
app.post("/webdesign/admin/anfragen/:id/archivieren", wrap(anfrageArchivieren));
|
||||
app.post("/webdesign/admin/anfragen/:id/notiz", wrap(anfrageNotiz));
|
||||
|
||||
/* Annehmen und Ablehnen.
|
||||
|
||||
Die Vorschau-Endpunkte lesen nur und veraendern nichts -- deshalb GET.
|
||||
Sie fuettern den Bestaetigungsdialog, damit der zeigen kann, WAS
|
||||
passieren wird (Termin, Zahl der Arbeitsschritte, ob der Kunde schon
|
||||
existiert), statt nur "wirklich?" zu fragen. */
|
||||
app.get("/webdesign/admin/anfragen/:id/annahme-vorschau", wrap(annahmeVorschau));
|
||||
app.post("/webdesign/admin/anfragen/:id/annehmen", wrap(anfrageAnnehmen));
|
||||
app.get("/webdesign/admin/anfragen/:id/absage-vorschau", wrap(absageVorschau));
|
||||
app.post("/webdesign/admin/anfragen/:id/ablehnen", wrap(anfrageAblehnen));
|
||||
|
||||
/* ---------- Kundenportal ----------
|
||||
Oeffentlich sind nur Anmeldung und das Einloesen eines Einladungslinks.
|
||||
Alles Weitere prueft die Kundensitzung im Handler selbst. */
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/* =====================================================================
|
||||
webdesign-termine.js — Laufzeiten, Werktage, Liefertermine
|
||||
|
||||
Hier entsteht die Zahl, die der Kunde als Zusage liest. Deshalb wird
|
||||
sie nicht überschlagen, sondern gerechnet.
|
||||
|
||||
DREI ENTSCHEIDUNGEN, DIE MAN BEGRÜNDEN MUSS
|
||||
|
||||
1. WERKTAGE, NICHT KALENDERTAGE.
|
||||
"In zehn Tagen" heißt bei zehn Kalendertagen: zwei Wochenenden
|
||||
sind schon weg, es bleiben sechs Arbeitstage. Wer so zusagt, sagt
|
||||
etwas zu, das er nicht halten kann. Gerechnet wird deshalb in
|
||||
Werktagen, und das Ergebnis ist ein Kalenderdatum.
|
||||
|
||||
2. FEIERTAGE ZÄHLEN MIT.
|
||||
Ein Liefertermin, der auf den luxemburgischen Nationalfeiertag
|
||||
fällt, ist keine Zusage, sondern ein Versehen. Die beweglichen
|
||||
Feiertage hängen am Osterdatum und werden deshalb berechnet, nicht
|
||||
als Liste gepflegt — eine Liste ist spätestens im übernächsten
|
||||
Jahr falsch, und zwar lautlos.
|
||||
|
||||
3. DIE LAUFZEITEN STEHEN IM CODE, NICHT IN DER DATENBANK.
|
||||
Sie sind eine Geschäftsentscheidung, keine Nutzereingabe. In der
|
||||
Datenbank müsste man sie pflegen, könnte sie versehentlich leeren,
|
||||
und ein leeres Feld ergäbe einen Termin von heute. Im Code sind
|
||||
sie überprüfbar und können nicht verschwinden. Abweichen kann man
|
||||
trotzdem: Der vorgeschlagene Termin ist beim Annehmen
|
||||
überschreibbar.
|
||||
===================================================================== */
|
||||
|
||||
/* Vorgesehene Laufzeit je Paket, in WERKTAGEN.
|
||||
|
||||
Die Zahlen sind bewusst großzügig gewählt. Ein Termin, den man
|
||||
regelmäßig reißt, richtet mehr Schaden an als einer, der von Anfang
|
||||
an ehrlich ist -- und wer früher fertig wird, macht damit eine
|
||||
Freude statt einer Entschuldigung. */
|
||||
export const LAUFZEIT_WERKTAGE = {
|
||||
onepager: 10, // eine Seite, klarer Umfang
|
||||
website: 20, // mehrere Seiten, Abstimmungsschleifen
|
||||
shop: 30, // Produkte, Bezahlung, Versand, Rechtstexte
|
||||
verwaltung: 15, // Adminbereich auf bestehender Grundlage
|
||||
betreuung: 0, // laufend -- hat keinen Liefertermin (siehe unten)
|
||||
unklar: 20, // bis geklärt ist, was es wird: mittlerer Wert
|
||||
};
|
||||
|
||||
/* Die Laufzeit eines Pakets -- an EINER Stelle aufgeloest.
|
||||
|
||||
Vorher lasen zwei Funktionen die Tabelle getrennt aus, und nur eine
|
||||
von beiden hatte einen Ersatzwert. Ein unbekanntes Paket bekam
|
||||
dadurch gar keinen Termin statt des mittleren Werts: Das Projekt lief
|
||||
ohne Zusage weiter, und niemand haette es bemerkt, weil eine fehlende
|
||||
Zahl nirgends auffaellt. Aufgefallen erst durch den Test.
|
||||
|
||||
Deshalb gibt es genau einen Ort, an dem aus einem Paket eine Zahl
|
||||
wird -- und nur der Sonderfall "laufende Betreuung" ergibt null. */
|
||||
export function laufzeitFuer(paket) {
|
||||
if (paket === "betreuung") return 0; // laufend, kein Ende
|
||||
const wert = LAUFZEIT_WERKTAGE[paket];
|
||||
return Number.isFinite(wert) ? wert : LAUFZEIT_WERKTAGE.unklar;
|
||||
}
|
||||
|
||||
/* Pakete ohne Liefertermin. Eine laufende Betreuung ist nie "fertig";
|
||||
ein Countdown darauf wäre sinnlos und würde im Portal irgendwann
|
||||
"abgelaufen" anzeigen, obwohl alles in Ordnung ist. */
|
||||
export function hatLiefertermin(paket) {
|
||||
return laufzeitFuer(paket) > 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
Ostersonntag nach der Gaußschen Osterformel (gregorianisch).
|
||||
|
||||
Gebraucht für Ostermontag, Christi Himmelfahrt und Pfingstmontag --
|
||||
die drei beweglichen Feiertage. Die Formel gilt für 1583 bis 2299,
|
||||
was für Liefertermine reichlich genügt.
|
||||
--------------------------------------------------------------------- */
|
||||
function ostersonntag(jahr) {
|
||||
const a = jahr % 19;
|
||||
const b = Math.floor(jahr / 100);
|
||||
const c = jahr % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const monat = Math.floor((h + l - 7 * m + 114) / 31); // 3 = März, 4 = April
|
||||
const tag = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return new Date(Date.UTC(jahr, monat - 1, tag));
|
||||
}
|
||||
|
||||
function plusTage(datum, n) {
|
||||
const d = new Date(datum.getTime());
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d;
|
||||
}
|
||||
|
||||
const alsSchluessel = (d) => d.toISOString().slice(0, 10);
|
||||
|
||||
/* Gesetzliche Feiertage in Luxemburg.
|
||||
|
||||
Warum Luxemburg: Dort sitzt der Betrieb, danach richtet sich, wann
|
||||
gearbeitet wird. Der Wohnort des Kunden ändert daran nichts -- wer in
|
||||
Deutschland bestellt, bekommt trotzdem am 23. Juni keine Arbeit
|
||||
geliefert. */
|
||||
const feiertagsSpeicher = new Map();
|
||||
|
||||
export function feiertage(jahr) {
|
||||
if (feiertagsSpeicher.has(jahr)) return feiertagsSpeicher.get(jahr);
|
||||
const o = ostersonntag(jahr);
|
||||
const tage = new Set([
|
||||
alsSchluessel(new Date(Date.UTC(jahr, 0, 1))), // Neujahr
|
||||
alsSchluessel(plusTage(o, 1)), // Ostermontag
|
||||
alsSchluessel(new Date(Date.UTC(jahr, 4, 1))), // Tag der Arbeit
|
||||
alsSchluessel(new Date(Date.UTC(jahr, 4, 9))), // Europatag
|
||||
alsSchluessel(plusTage(o, 39)), // Christi Himmelfahrt
|
||||
alsSchluessel(plusTage(o, 50)), // Pfingstmontag
|
||||
alsSchluessel(new Date(Date.UTC(jahr, 5, 23))), // Nationalfeiertag
|
||||
alsSchluessel(new Date(Date.UTC(jahr, 7, 15))), // Mariä Himmelfahrt
|
||||
alsSchluessel(new Date(Date.UTC(jahr, 10, 1))), // Allerheiligen
|
||||
alsSchluessel(new Date(Date.UTC(jahr, 11, 25))), // Weihnachten
|
||||
alsSchluessel(new Date(Date.UTC(jahr, 11, 26))), // Zweiter Weihnachtstag
|
||||
]);
|
||||
feiertagsSpeicher.set(jahr, tage);
|
||||
return tage;
|
||||
}
|
||||
|
||||
export function istWerktag(datum) {
|
||||
const wt = datum.getUTCDay();
|
||||
if (wt === 0 || wt === 6) return false; // Sonntag, Samstag
|
||||
return !feiertage(datum.getUTCFullYear()).has(alsSchluessel(datum));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zählt `werktage` Werktage ab `ab` und liefert das Kalenderdatum.
|
||||
*
|
||||
* Der Starttag selbst zählt NICHT mit. Wer am Montag zusagt und
|
||||
* "1 Werktag" vorsieht, meint Dienstag -- nicht "heute noch".
|
||||
*
|
||||
* @param {Date|string} ab Startpunkt (Datum oder ISO-Text)
|
||||
* @param {number} werktage Anzahl Werktage
|
||||
* @returns {string|null} Datum als JJJJ-MM-TT, oder null
|
||||
*/
|
||||
export function werktageAddieren(ab, werktage) {
|
||||
const n = Number(werktage);
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
/* Auf UTC-Mitternacht normieren. Ohne das schleppt man die Uhrzeit
|
||||
mit, und ein Termin könnte je nach Tageszeit einen Tag springen. */
|
||||
const start = new Date(ab);
|
||||
if (isNaN(start)) return null;
|
||||
let d = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate()));
|
||||
|
||||
let offen = Math.min(n, 500); // Deckel gegen eine Endlosschleife
|
||||
while (offen > 0) {
|
||||
d = plusTage(d, 1);
|
||||
if (istWerktag(d)) offen--;
|
||||
}
|
||||
return alsSchluessel(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbleibende WERKTAGE bis zu einem Termin.
|
||||
*
|
||||
* Bewusst Werktage und nicht Kalendertage: "Noch 2 Tage" am Freitag
|
||||
* klingt nach Montag, gemeint ist aber Dienstag. Wer danach plant,
|
||||
* plant falsch.
|
||||
*
|
||||
* @returns {number} negativ, wenn der Termin überschritten ist
|
||||
*/
|
||||
export function werktageBis(termin, vonHeute) {
|
||||
if (!termin) return null;
|
||||
const ziel = new Date(termin + "T00:00:00Z");
|
||||
if (isNaN(ziel)) return null;
|
||||
const jetzt = vonHeute ? new Date(vonHeute) : new Date();
|
||||
let d = new Date(Date.UTC(jetzt.getUTCFullYear(), jetzt.getUTCMonth(), jetzt.getUTCDate()));
|
||||
|
||||
if (alsSchluessel(d) === termin) return 0;
|
||||
|
||||
const rueckwaerts = ziel < d;
|
||||
let zahl = 0;
|
||||
let schutz = 0;
|
||||
while (alsSchluessel(d) !== termin && schutz++ < 2000) {
|
||||
d = plusTage(d, rueckwaerts ? -1 : 1);
|
||||
if (istWerktag(d)) zahl++;
|
||||
}
|
||||
return rueckwaerts ? -zahl : zahl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschlag für einen Liefertermin beim Annehmen einer Anfrage.
|
||||
*
|
||||
* @returns {{termin: string|null, werktage: number, laufend: boolean}}
|
||||
*/
|
||||
export function terminVorschlag(paket, ab) {
|
||||
const werktage = laufzeitFuer(paket);
|
||||
if (werktage <= 0) return { termin: null, werktage: 0, laufend: true };
|
||||
return {
|
||||
termin: werktageAddieren(ab || new Date(), werktage),
|
||||
werktage,
|
||||
laufend: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
/* =====================================================================
|
||||
webdesign-annahme.js — eine Anfrage annehmen oder ablehnen
|
||||
|
||||
WAS HIER PASSIERT, UND WARUM ES EIN EIGENER SCHRITT IST
|
||||
|
||||
Bisher konnte man den Status einer Anfrage auf "angenommen" stellen.
|
||||
Passiert ist dabei nichts: kein Kunde, kein Projekt, keine
|
||||
Aufgabenliste, kein Zugang. Man musste danach von Hand einen Kunden
|
||||
anlegen, ein Projekt anlegen, die Vorlage anwenden, einen Termin
|
||||
eintragen und eine Einladung erzeugen — fünf Schritte, bei denen man
|
||||
einen vergessen kann. Vergisst man den letzten, wartet der Kunde auf
|
||||
einen Zugang, der nie kommt, und man selbst hält den Vorgang für
|
||||
erledigt.
|
||||
|
||||
"Annehmen" ist deshalb jetzt EIN Vorgang, der alles davon tut.
|
||||
|
||||
ALLES ODER NICHTS
|
||||
Alle Schritte laufen in einer Transaktion. Bricht einer ab, bleibt
|
||||
nichts Halbes zurück. Ein Kunde ohne Projekt wäre schlimmer als gar
|
||||
nichts: Er stünde in der Liste, hätte einen Zugang und sähe darin
|
||||
eine leere Seite.
|
||||
|
||||
NUR EINMAL
|
||||
Ein zweiter Klick — versehentlich, oder weil die Antwort lange
|
||||
brauchte — darf kein zweites Projekt anlegen. Deshalb wird zuerst
|
||||
geprüft, ob zu dieser Anfrage schon ein Projekt existiert. Ist es so,
|
||||
kommt dasselbe Ergebnis zurück wie beim ersten Mal, statt eines
|
||||
Fehlers: Für den Bedienenden ist das Ergebnis dasselbe, und ein
|
||||
Fehler an dieser Stelle würde nur verunsichern.
|
||||
===================================================================== */
|
||||
import { db } from "../db.js";
|
||||
import { json } from "../lib/http.js";
|
||||
import { darfWebdesign } from "../lib/webdesign-ausweis.js";
|
||||
import { requireSession } from "../lib/auth.js";
|
||||
import { hasPermission } from "../lib/permissions.js";
|
||||
import {
|
||||
jetzt, neueId, naechsteNummer, text, verlauf, PAKETE,
|
||||
} from "../lib/webdesign-helfer.js";
|
||||
import { terminVorschlag, werktageBis, laufzeitFuer, hatLiefertermin }
|
||||
from "../lib/webdesign-termine.js";
|
||||
import { vorlageAnwenden } from "./webdesign-aufgaben.js";
|
||||
import { einladungAnlegen, einladungsLink } from "./webdesign-kunden.js";
|
||||
|
||||
async function darf(req, recht) {
|
||||
return darfWebdesign(req, recht, async () => {
|
||||
const sitzung = await requireSession(req);
|
||||
if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null };
|
||||
if (sitzung.isOwner) return { erlaubt: true, sitzung };
|
||||
const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht);
|
||||
return { erlaubt: ok, grund: ok ? null : 403, sitzung };
|
||||
});
|
||||
}
|
||||
/* Wer war das?
|
||||
|
||||
Zwei Wege fuehren herein, und sie liefern verschiedene Formen:
|
||||
ueber den Ausweis der Zugangswand kommt {actor, ueberAusweis}, ueber
|
||||
eine Team-Anmeldung kommt {sitzung}. Wer nur eine der beiden Formen
|
||||
liest, schreibt bei jedem zweiten Vorgang "unbekannt" in den Verlauf
|
||||
-- und ausgerechnet beim Annehmen und Ablehnen will man spaeter
|
||||
wissen, wer entschieden hat. */
|
||||
function werIst(p) {
|
||||
if (p.actor) return p.actor; // Ausweis der Zugangswand
|
||||
if (p.sitzung && p.sitzung.actor) return "team:" + p.sitzung.actor;
|
||||
return "unbekannt";
|
||||
}
|
||||
|
||||
/* Ein Projekttitel, der nach etwas aussieht.
|
||||
|
||||
Der Kunde liest ihn in seinem Portal, also nicht "Anfrage A-2608-0012".
|
||||
Firma vor Name: "Website für Musterbau GmbH" sagt mehr als "Website
|
||||
für Anna Muster", wenn beides da ist. */
|
||||
function titelBauen(anfrage) {
|
||||
const PAKETNAME = {
|
||||
onepager: "Onepager", website: "Website", shop: "Onlineshop",
|
||||
verwaltung: "Verwaltungsbereich", betreuung: "Betreuung", unklar: "Projekt",
|
||||
};
|
||||
const was = PAKETNAME[anfrage.paket] || "Projekt";
|
||||
/* Kein Firmenfeld: wd_anfragen kennt nur einen Namen. Das
|
||||
Anfrageformular fragt bewusst nicht nach einer Firma -- die meisten
|
||||
Anfragen kommen von Einzelpersonen, und ein leeres Pflichtfeld
|
||||
schreckt ab. */
|
||||
const wer = (anfrage.name || "").trim();
|
||||
return wer ? `${was} für ${wer}` : was;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
VORSCHAU — was passiert, wenn ich jetzt annehme?
|
||||
|
||||
Getrennt vom Annehmen selbst, damit der Dialog etwas zeigen kann,
|
||||
BEVOR etwas geschieht. Ein Bestätigungsdialog, der nur "wirklich?"
|
||||
fragt, hilft niemandem; einer, der den Termin, die Zahl der
|
||||
Arbeitsschritte und den bereits bekannten Kunden nennt, schon.
|
||||
--------------------------------------------------------------------- */
|
||||
export async function annahmeVorschau(req, res) {
|
||||
const p = await darf(req, "WD_PROJEKTE_MANAGE");
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
const anfrage = db.prepare(`SELECT * FROM wd_anfragen WHERE id = ?`)
|
||||
.get(String(req.params.id || ""));
|
||||
if (!anfrage) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
|
||||
|
||||
const paket = PAKETE.includes(anfrage.paket) ? anfrage.paket : "unklar";
|
||||
const v = terminVorschlag(paket, new Date());
|
||||
|
||||
/* Gibt es den Menschen schon? E-Mail-Vergleich ohne Rücksicht auf
|
||||
Groß- und Kleinschreibung: "Anna@..." und "anna@..." sind derselbe
|
||||
Mensch, und zwei Kundeneinträge für eine Person sind später kaum
|
||||
wieder zu trennen. */
|
||||
const kunde = anfrage.email
|
||||
? db.prepare(`SELECT id, name, email, freigeschaltet FROM wd_kunden
|
||||
WHERE lower(email) = lower(?) AND archiviert = 0`).get(anfrage.email)
|
||||
: null;
|
||||
|
||||
const schritte = db.prepare(
|
||||
`SELECT COUNT(*) AS n FROM wd_aufgaben_vorlagen WHERE paket = ? AND aktiv = 1`
|
||||
).get(paket);
|
||||
|
||||
const schonda = db.prepare(
|
||||
`SELECT id, nummer FROM wd_projekte WHERE anfrage_id = ?`
|
||||
).get(anfrage.id);
|
||||
|
||||
return json(res, {
|
||||
ok: true,
|
||||
paket,
|
||||
titelVorschlag: titelBauen(anfrage),
|
||||
terminVorschlag: v.termin,
|
||||
werktage: v.werktage,
|
||||
laufend: v.laufend,
|
||||
schritte: schritte ? schritte.n : 0,
|
||||
kunde: kunde || null,
|
||||
kundeNeu: !kunde,
|
||||
bereitsAngenommen: schonda || null,
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
ANNEHMEN
|
||||
--------------------------------------------------------------------- */
|
||||
export async function anfrageAnnehmen(req, res) {
|
||||
const p = await darf(req, "WD_PROJEKTE_MANAGE");
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
const anfrage = db.prepare(`SELECT * FROM wd_anfragen WHERE id = ?`)
|
||||
.get(String(req.params.id || ""));
|
||||
if (!anfrage) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
|
||||
|
||||
/* Schon angenommen? Dann nicht noch einmal. Siehe Kopfkommentar:
|
||||
dieselbe Antwort, kein Fehler. */
|
||||
const schonda = db.prepare(
|
||||
`SELECT id, nummer FROM wd_projekte WHERE anfrage_id = ?`
|
||||
).get(anfrage.id);
|
||||
if (schonda) {
|
||||
return json(res, {
|
||||
ok: true, bereitsAngenommen: true,
|
||||
projektId: schonda.id, projektNummer: schonda.nummer,
|
||||
hinweis: "Diese Anfrage war bereits angenommen — es wurde nichts doppelt angelegt.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!anfrage.email) {
|
||||
return json(res, { ok: false,
|
||||
error: "Diese Anfrage hat keine E-Mail-Adresse. Ohne sie kann kein Zugang entstehen." }, 400);
|
||||
}
|
||||
|
||||
const paket = PAKETE.includes(anfrage.paket) ? anfrage.paket : "unklar";
|
||||
const titel = text(req.body?.titel, 160) || titelBauen(anfrage);
|
||||
|
||||
/* Termin: Vorschlag, sofern nichts anderes kommt. Ein von Hand
|
||||
gesetzter Termin wird auf seine Form geprüft -- ein "31.13.2026"
|
||||
aus einem Tippfehler darf nicht in die Datenbank und dort als
|
||||
Zusage stehen. */
|
||||
const gewuenscht = text(req.body?.termin, 10);
|
||||
let termin = null;
|
||||
let werktage = laufzeitFuer(paket);
|
||||
if (gewuenscht) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(gewuenscht) || isNaN(new Date(gewuenscht + "T00:00:00Z"))) {
|
||||
return json(res, { ok: false, error: "Das Terminformat ist ungültig (erwartet: JJJJ-MM-TT)." }, 400);
|
||||
}
|
||||
termin = gewuenscht;
|
||||
werktage = werktageBis(termin, new Date());
|
||||
} else if (hatLiefertermin(paket)) {
|
||||
termin = terminVorschlag(paket, new Date()).termin;
|
||||
}
|
||||
|
||||
const preisEuro = Number(req.body?.preisEuro);
|
||||
const preisCent = Number.isFinite(preisEuro) && preisEuro >= 0 ? Math.round(preisEuro * 100) : null;
|
||||
/* Wie beim Anlegen von Hand: Die 30 % werden gerechnet, nicht
|
||||
eingetippt. Ein Tippfehler in der Anzahlung faellt sonst erst beim
|
||||
Geldeingang auf. */
|
||||
const anzahlungCent = preisCent != null ? Math.round(preisCent * 0.3) : null;
|
||||
|
||||
let kundeId = null;
|
||||
let kundeWarNeu = false;
|
||||
let projektId = null;
|
||||
let projektNummer = null;
|
||||
let rohToken = null;
|
||||
let angelegteSchritte = 0;
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
/* ---- 1. Kunde: finden oder anlegen ---- */
|
||||
const vorhanden = db.prepare(
|
||||
`SELECT id FROM wd_kunden WHERE lower(email) = lower(?) AND archiviert = 0`
|
||||
).get(anfrage.email);
|
||||
|
||||
if (vorhanden) {
|
||||
kundeId = vorhanden.id;
|
||||
/* Ein gesperrter Bestandskunde wird durch eine Annahme NICHT
|
||||
still entsperrt -- das wäre eine Entscheidung, die hier
|
||||
niemand getroffen hat. Freigeschaltet wird nur, wer noch
|
||||
nicht freigeschaltet war. */
|
||||
db.prepare(
|
||||
`UPDATE wd_kunden SET freigeschaltet = 1, aktualisiert_am = ?
|
||||
WHERE id = ? AND gesperrt = 0`
|
||||
).run(jetzt(), kundeId);
|
||||
} else {
|
||||
kundeWarNeu = true;
|
||||
kundeId = neueId();
|
||||
db.prepare(
|
||||
`INSERT INTO wd_kunden (id, email, name, firma, telefon, sprache,
|
||||
email_bestaetigt, freigeschaltet, gesperrt, erstellt_am)
|
||||
VALUES (?,?,?,?,?,?,0,1,0,?)`
|
||||
).run(
|
||||
kundeId, anfrage.email, anfrage.name || anfrage.email,
|
||||
/* Firma gibt es in einer Anfrage nicht -- bewusst null statt
|
||||
undefined: better-sqlite3 weist undefined mit einem Fehler
|
||||
ab, und der haette die ganze Annahme scheitern lassen. */
|
||||
null, anfrage.telefon || null,
|
||||
anfrage.sprache || "de", jetzt()
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- 2. Projekt ---- */
|
||||
projektId = neueId();
|
||||
projektNummer = naechsteNummer("wd_projekte", "P");
|
||||
db.prepare(
|
||||
`INSERT INTO wd_projekte
|
||||
(id, nummer, kunde_id, anfrage_id, titel, paket, beschreibung,
|
||||
status, naechster_schritt, wartet_auf, preis_cent, anzahlung_cent,
|
||||
waehrung, start_am, termin_am, angenommen_am, termin_werktage, erstellt_am)
|
||||
VALUES (?,?,?,?,?,?,?,'briefing',?,'kunde',?,?,'EUR',?,?,?,?,?)`
|
||||
).run(
|
||||
projektId, projektNummer, kundeId, anfrage.id, titel, paket,
|
||||
anfrage.details || null,
|
||||
/* Der erste Schritt liegt beim KUNDEN, nicht bei mir: Ohne
|
||||
Texte, Bilder und Zugänge kann nichts beginnen. Stünde hier
|
||||
"dogfather", sähe der Kunde in seinem Portal "wird bearbeitet"
|
||||
und wüsste nicht, dass auf ihn gewartet wird. */
|
||||
"Unterlagen zusammenstellen — Texte, Bilder, Zugänge",
|
||||
preisCent, anzahlungCent,
|
||||
jetzt(), termin, jetzt(), werktage, jetzt()
|
||||
);
|
||||
|
||||
/* ---- 3. Aufgabenliste aus der Vorlage ---- */
|
||||
const v = vorlageAnwenden(projektId, kundeId, paket);
|
||||
angelegteSchritte = v.neu;
|
||||
|
||||
/* ---- 4. Anfrage als angenommen vermerken ---- */
|
||||
db.prepare(
|
||||
`UPDATE wd_anfragen SET status = 'angenommen', kunde_id = ?, aktualisiert_am = ?
|
||||
WHERE id = ?`
|
||||
).run(kundeId, jetzt(), anfrage.id);
|
||||
|
||||
/* ---- 5. Zugang ----
|
||||
Der Link entsteht INNERHALB der Transaktion, weil er ein
|
||||
Datenbankeintrag ist. Scheitert etwas danach, gibt es auch
|
||||
keinen halb gültigen Zugang. */
|
||||
if (kundeWarNeu) rohToken = einladungAnlegen(kundeId, "einladung");
|
||||
})();
|
||||
} catch (fehler) {
|
||||
return json(res, { ok: false,
|
||||
error: "Beim Annehmen ist etwas schiefgegangen — es wurde nichts angelegt.",
|
||||
einzelheit: String(fehler && fehler.message || fehler).slice(0, 300) }, 500);
|
||||
}
|
||||
|
||||
verlauf("anfrage", anfrage.id, "angenommen", werIst(p),
|
||||
`${projektNummer} · ${paket}${termin ? " · Termin " + termin : ""}`);
|
||||
verlauf("projekt", projektId, "angelegt_aus_anfrage", werIst(p),
|
||||
`${angelegteSchritte} Arbeitsschritte`);
|
||||
|
||||
return json(res, {
|
||||
ok: true,
|
||||
projektId,
|
||||
projektNummer,
|
||||
kundeId,
|
||||
kundeNeu: kundeWarNeu,
|
||||
termin,
|
||||
werktage,
|
||||
schritte: angelegteSchritte,
|
||||
/* Der Link ist nur JETZT sichtbar. Danach steht in der Datenbank
|
||||
nur noch sein Abdruck -- wie beim Anlegen von Hand auch. */
|
||||
einladungsLink: rohToken ? einladungsLink(rohToken) : null,
|
||||
hinweis: kundeWarNeu
|
||||
? "Der Einladungslink ist nur jetzt sichtbar. Schick ihn dem Kunden."
|
||||
: "Der Kunde hatte bereits einen Zugang — er sieht das Projekt sofort.",
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
ABLEHNEN
|
||||
|
||||
Der Absagetext entsteht auf dem SERVER, nicht im Browser.
|
||||
|
||||
Er ist eine geschäftliche Äußerung, die beim Empfänger ankommt. Käme
|
||||
er aus dem Browser, könnte man ihn unterwegs verändern — und im
|
||||
Nachhinein wäre nicht mehr feststellbar, was tatsächlich geschrieben
|
||||
wurde. Derselbe Grund, aus dem der Zustimmungstext beim Bezahlen
|
||||
serverseitig festgehalten wird.
|
||||
--------------------------------------------------------------------- */
|
||||
export const ABLEHNUNGSGRUENDE = [
|
||||
"kein_zeitfenster", "passt_nicht", "budget", "zu_gross", "kein_kontakt", "sonstiges",
|
||||
];
|
||||
|
||||
/* Fünf Sprachen, weil die Seite fünf Sprachen hat. Eine Absage auf
|
||||
Deutsch an jemanden, der auf Portugiesisch angefragt hat, ist die
|
||||
letzte Nachricht, die dieser Mensch von hier bekommt -- die sollte
|
||||
sitzen. */
|
||||
const ABSAGE = {
|
||||
de: {
|
||||
anrede: (n) => `Hallo ${n},`,
|
||||
dank: "vielen Dank für deine Anfrage und das Vertrauen.",
|
||||
gruende: {
|
||||
kein_zeitfenster: "Leider habe ich im gewünschten Zeitraum keine freien Kapazitäten, um dein Projekt so umzusetzen, wie es das verdient hätte.",
|
||||
passt_nicht: "Nach dem Durchlesen glaube ich, dass ich nicht der Richtige dafür bin — du wärst bei jemandem mit einem anderen Schwerpunkt besser aufgehoben.",
|
||||
budget: "Der Aufwand, den dein Vorhaben braucht, passt leider nicht zum vorgesehenen Budget. Ich möchte dir lieber absagen, als etwas Halbes zu bauen.",
|
||||
zu_gross: "Dein Vorhaben ist größer, als ich es allein sauber stemmen kann. Eine Zusage wäre unehrlich.",
|
||||
kein_kontakt: "Leider konnte ich dich trotz mehrerer Versuche nicht erreichen. Ich schließe die Anfrage deshalb vorerst.",
|
||||
sonstiges: "Leider kann ich dein Projekt derzeit nicht übernehmen.",
|
||||
},
|
||||
schluss: "Wenn sich bei dir etwas ändert oder du später noch einmal anfragen möchtest, melde dich jederzeit gern.",
|
||||
gruss: "Viele Grüße\nDogfather Webdesign",
|
||||
},
|
||||
"de-CH": {
|
||||
anrede: (n) => `Hallo ${n},`,
|
||||
dank: "vielen Dank für deine Anfrage und das Vertrauen.",
|
||||
gruende: {
|
||||
kein_zeitfenster: "Leider habe ich im gewünschten Zeitraum keine freien Kapazitäten, um dein Projekt so umzusetzen, wie es das verdient hätte.",
|
||||
passt_nicht: "Nach dem Durchlesen glaube ich, dass ich nicht der Richtige dafür bin — du wärst bei jemandem mit einem anderen Schwerpunkt besser aufgehoben.",
|
||||
budget: "Der Aufwand, den dein Vorhaben braucht, passt leider nicht zum vorgesehenen Budget. Ich möchte dir lieber absagen, als etwas Halbes zu bauen.",
|
||||
zu_gross: "Dein Vorhaben ist grösser, als ich es allein sauber stemmen kann. Eine Zusage wäre unehrlich.",
|
||||
kein_kontakt: "Leider konnte ich dich trotz mehrerer Versuche nicht erreichen. Ich schliesse die Anfrage deshalb vorerst.",
|
||||
sonstiges: "Leider kann ich dein Projekt derzeit nicht übernehmen.",
|
||||
},
|
||||
schluss: "Wenn sich bei dir etwas ändert oder du später noch einmal anfragen möchtest, melde dich jederzeit gern.",
|
||||
gruss: "Viele Grüsse\nDogfather Webdesign",
|
||||
},
|
||||
en: {
|
||||
anrede: (n) => `Hello ${n},`,
|
||||
dank: "thank you for your enquiry and for thinking of me.",
|
||||
gruende: {
|
||||
kein_zeitfenster: "Unfortunately I have no capacity in the period you need, and I would not be able to give your project the attention it deserves.",
|
||||
passt_nicht: "Having read it through, I do not think I am the right person for this — someone with a different focus would serve you better.",
|
||||
budget: "The effort your project needs does not match the budget you have in mind. I would rather turn it down than build something half-finished.",
|
||||
zu_gross: "Your project is larger than I can properly handle on my own. Saying yes would not be honest.",
|
||||
kein_kontakt: "Unfortunately I was unable to reach you despite several attempts, so I am closing the enquiry for now.",
|
||||
sonstiges: "Unfortunately I am not able to take on your project at the moment.",
|
||||
},
|
||||
schluss: "If anything changes at your end, or you would like to get in touch again later, you are very welcome to do so.",
|
||||
gruss: "Best regards\nDogfather Webdesign",
|
||||
},
|
||||
fr: {
|
||||
anrede: (n) => `Bonjour ${n},`,
|
||||
dank: "merci beaucoup pour votre demande et pour votre confiance.",
|
||||
gruende: {
|
||||
kein_zeitfenster: "Malheureusement, je n'ai pas de disponibilité sur la période souhaitée pour réaliser votre projet comme il le mériterait.",
|
||||
passt_nicht: "Après lecture, je ne pense pas être la bonne personne pour ce projet — quelqu'un avec une autre spécialité vous conviendrait mieux.",
|
||||
budget: "Le travail nécessaire ne correspond malheureusement pas au budget prévu. Je préfère refuser plutôt que de livrer quelque chose d'incomplet.",
|
||||
zu_gross: "Votre projet est plus important que ce que je peux mener seul correctement. Accepter ne serait pas honnête.",
|
||||
kein_kontakt: "Malheureusement, je n'ai pas réussi à vous joindre malgré plusieurs tentatives. Je clôture donc la demande pour le moment.",
|
||||
sonstiges: "Malheureusement, je ne peux pas prendre en charge votre projet actuellement.",
|
||||
},
|
||||
schluss: "Si les choses évoluent de votre côté ou si vous souhaitez me recontacter plus tard, n'hésitez surtout pas.",
|
||||
gruss: "Bien cordialement\nDogfather Webdesign",
|
||||
},
|
||||
pt: {
|
||||
anrede: (n) => `Olá ${n},`,
|
||||
dank: "muito obrigado pelo seu pedido e pela confiança.",
|
||||
gruende: {
|
||||
kein_zeitfenster: "Infelizmente não tenho disponibilidade no período pretendido para realizar o seu projeto como ele merecia.",
|
||||
passt_nicht: "Depois de ler com atenção, acho que não sou a pessoa certa para isto — alguém com outra especialidade serviria melhor.",
|
||||
budget: "O trabalho necessário não corresponde ao orçamento previsto. Prefiro recusar a entregar algo incompleto.",
|
||||
zu_gross: "O seu projeto é maior do que aquilo que consigo fazer sozinho com qualidade. Aceitar não seria honesto.",
|
||||
kein_kontakt: "Infelizmente não consegui contactá-lo apesar de várias tentativas, pelo que encerro o pedido por agora.",
|
||||
sonstiges: "Infelizmente não posso assumir o seu projeto neste momento.",
|
||||
},
|
||||
schluss: "Se algo mudar do seu lado, ou se quiser voltar a contactar-me mais tarde, será muito bem-vindo.",
|
||||
gruss: "Com os melhores cumprimentos\nDogfather Webdesign",
|
||||
},
|
||||
};
|
||||
|
||||
export function absageText(sprache, grund, name) {
|
||||
const s = ABSAGE[sprache] || ABSAGE.de;
|
||||
const g = s.gruende[grund] || s.gruende.sonstiges;
|
||||
const wer = (name || "").trim() || (sprache === "fr" ? "Madame, Monsieur" : "");
|
||||
return [
|
||||
wer ? s.anrede(wer) : s.anrede("").replace(/\s+,$/, ","),
|
||||
"",
|
||||
s.dank,
|
||||
"",
|
||||
g,
|
||||
"",
|
||||
s.schluss,
|
||||
"",
|
||||
s.gruss,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/* Vorschau des Absagetexts, ohne etwas zu verändern. */
|
||||
export async function absageVorschau(req, res) {
|
||||
const p = await darf(req, "WD_ANFRAGEN_MANAGE");
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
const anfrage = db.prepare(`SELECT name, sprache FROM wd_anfragen WHERE id = ?`)
|
||||
.get(String(req.params.id || ""));
|
||||
if (!anfrage) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
|
||||
|
||||
const grund = text(req.query?.grund, 40) || "sonstiges";
|
||||
return json(res, {
|
||||
ok: true,
|
||||
sprache: anfrage.sprache || "de",
|
||||
grund: ABLEHNUNGSGRUENDE.includes(grund) ? grund : "sonstiges",
|
||||
text: absageText(anfrage.sprache || "de",
|
||||
ABLEHNUNGSGRUENDE.includes(grund) ? grund : "sonstiges", anfrage.name),
|
||||
});
|
||||
}
|
||||
|
||||
export async function anfrageAblehnen(req, res) {
|
||||
const p = await darf(req, "WD_ANFRAGEN_MANAGE");
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
const anfrage = db.prepare(`SELECT * FROM wd_anfragen WHERE id = ?`)
|
||||
.get(String(req.params.id || ""));
|
||||
if (!anfrage) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
|
||||
|
||||
/* Eine angenommene Anfrage abzulehnen wäre ein Widerspruch: Das
|
||||
Projekt läuft bereits, der Kunde sieht es in seinem Portal. Wer
|
||||
wirklich abbrechen will, tut das am Projekt (Status "abgebrochen")
|
||||
-- dort hängen Zahlungen und Widerrufsfristen dran. */
|
||||
const schonda = db.prepare(`SELECT nummer FROM wd_projekte WHERE anfrage_id = ?`).get(anfrage.id);
|
||||
if (schonda) {
|
||||
return json(res, { ok: false,
|
||||
error: `Zu dieser Anfrage läuft bereits das Projekt ${schonda.nummer}. Brich es dort ab, statt die Anfrage abzulehnen.` }, 409);
|
||||
}
|
||||
|
||||
const grund = ABLEHNUNGSGRUENDE.includes(text(req.body?.grund, 40))
|
||||
? text(req.body?.grund, 40) : "sonstiges";
|
||||
const senden = req.body?.senden === true;
|
||||
|
||||
const sprache = anfrage.sprache || "de";
|
||||
const nachricht = absageText(sprache, grund, anfrage.name);
|
||||
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
`UPDATE wd_anfragen
|
||||
SET status = 'abgelehnt', ablehnung_grund = ?, ablehnung_am = ?,
|
||||
absage_gesendet_am = ?, aktualisiert_am = ?
|
||||
WHERE id = ?`
|
||||
).run(grund, jetzt(), senden ? jetzt() : null, jetzt(), anfrage.id);
|
||||
|
||||
/* Die Absage landet im Postfach, wenn es einen Kundeneintrag gibt.
|
||||
Ohne Kundeneintrag gibt es keinen Ort dafür -- dann bekommt man
|
||||
den Text zum Selbstverschicken zurück, statt ihn ins Leere zu
|
||||
schreiben und "gesendet" zu vermerken. */
|
||||
if (senden && anfrage.kunde_id) {
|
||||
db.prepare(
|
||||
`INSERT INTO wd_postfach (id, kunde_id, autor, text, gelesen_kunde, gelesen_admin, intern, erstellt_am)
|
||||
VALUES (?,?, 'dogfather', ?, 0, 1, 0, ?)`
|
||||
).run(neueId(), anfrage.kunde_id, nachricht, jetzt());
|
||||
}
|
||||
})();
|
||||
|
||||
verlauf("anfrage", anfrage.id, "abgelehnt", werIst(p),
|
||||
grund + (senden ? " · Absage verschickt" : " · nur intern"));
|
||||
|
||||
const imPostfach = senden && !!anfrage.kunde_id;
|
||||
return json(res, {
|
||||
ok: true,
|
||||
grund,
|
||||
sprache,
|
||||
text: nachricht,
|
||||
imPostfach,
|
||||
hinweis: senden && !imPostfach
|
||||
? "Der Mensch hat noch keinen Kundenzugang — der Text steht hier zum Kopieren, verschick ihn per E-Mail."
|
||||
: null,
|
||||
});
|
||||
}
|
||||
@@ -348,31 +348,31 @@ export async function adminAufgabeLoeschen(req, res) {
|
||||
Funktion angelegt wurden, oder wenn eine Liste versehentlich geleert
|
||||
wurde. Fügt HINZU statt zu ersetzen: Vorhandene Punkte könnten schon
|
||||
abgehakt sein, und ein Ersetzen würde diesen Fortschritt löschen. */
|
||||
export async function adminVorlageAnwenden(req, res) {
|
||||
const p = await darf(req, "WD_PROJEKTE_MANAGE");
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
const projekt = db
|
||||
.prepare(`SELECT id, kunde_id, paket FROM wd_projekte WHERE id = ?`)
|
||||
.get(String(req.params.id || ""));
|
||||
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
|
||||
|
||||
const paket = text(req.body?.paket, 40) || projekt.paket;
|
||||
/**
|
||||
* Wendet die Aufgabenvorlage eines Pakets auf ein Projekt an.
|
||||
*
|
||||
* Herausgeloest aus dem Endpunkt, weil die Annahme einer Anfrage
|
||||
* dasselbe braucht. Kopiert man den Ablauf statt ihn zu teilen, laufen
|
||||
* beide Fassungen mit der Zeit auseinander -- und dann bekommen von
|
||||
* Hand angelegte Projekte eine andere Liste als angenommene, ohne dass
|
||||
* es jemand merkt.
|
||||
*
|
||||
* @returns {{neu:number, uebersprungen:number, gesamt:number}}
|
||||
*/
|
||||
export function vorlageAnwenden(projektId, kundeId, paket) {
|
||||
const vorlage = db
|
||||
.prepare(
|
||||
`SELECT titel, beschreibung, kategorie, wer_dran, reihenfolge
|
||||
FROM wd_aufgaben_vorlagen WHERE paket = ? AND aktiv = 1 ORDER BY reihenfolge`
|
||||
)
|
||||
.all(paket);
|
||||
if (!vorlage.length) {
|
||||
return json(res, { ok: false, error: "Für dieses Paket gibt es keine Vorlage." }, 404);
|
||||
}
|
||||
if (!vorlage.length) return { neu: 0, uebersprungen: 0, gesamt: 0 };
|
||||
|
||||
/* Titel, die schon in der Liste stehen, werden übersprungen. Sonst
|
||||
erzeugt ein zweiter Klick eine doppelte Liste — und das merkt man
|
||||
/* Titel, die schon in der Liste stehen, werden uebersprungen. Sonst
|
||||
erzeugt ein zweiter Klick eine doppelte Liste -- und das merkt man
|
||||
erst, wenn der Kunde sie sieht. */
|
||||
const vorhanden = new Set(
|
||||
db.prepare(`SELECT titel FROM wd_aufgaben WHERE projekt_id = ?`).all(projekt.id)
|
||||
db.prepare(`SELECT titel FROM wd_aufgaben WHERE projekt_id = ?`).all(projektId)
|
||||
.map((z) => z.titel.toLowerCase())
|
||||
);
|
||||
|
||||
@@ -386,17 +386,41 @@ export async function adminVorlageAnwenden(req, res) {
|
||||
db.transaction(() => {
|
||||
for (const v of vorlage) {
|
||||
if (vorhanden.has(v.titel.toLowerCase())) continue;
|
||||
einfuegen.run(neueId(), projekt.id, projekt.kunde_id, v.titel, v.beschreibung,
|
||||
einfuegen.run(neueId(), projektId, kundeId, v.titel, v.beschreibung,
|
||||
v.kategorie, v.wer_dran, v.reihenfolge, jetzt());
|
||||
neu++;
|
||||
}
|
||||
})();
|
||||
return { neu, uebersprungen: vorlage.length - neu, gesamt: vorlage.length };
|
||||
}
|
||||
|
||||
verlauf("projekt", projekt.id, "vorlage_angewendet", werIst(p), `${paket}: ${neu} Punkte`);
|
||||
export async function adminVorlageAnwenden(req, res) {
|
||||
const p = await darf(req, "WD_PROJEKTE_MANAGE");
|
||||
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
||||
|
||||
const projekt = db
|
||||
.prepare(`SELECT id, kunde_id, paket FROM wd_projekte WHERE id = ?`)
|
||||
.get(String(req.params.id || ""));
|
||||
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
|
||||
|
||||
const paket = text(req.body?.paket, 40) || projekt.paket;
|
||||
const vorhandeneVorlage = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n
|
||||
FROM wd_aufgaben_vorlagen WHERE paket = ? AND aktiv = 1`
|
||||
)
|
||||
.get(paket);
|
||||
if (!vorhandeneVorlage || !vorhandeneVorlage.n) {
|
||||
return json(res, { ok: false, error: "Für dieses Paket gibt es keine Vorlage." }, 404);
|
||||
}
|
||||
|
||||
const ergebnis = vorlageAnwenden(projekt.id, projekt.kunde_id, paket);
|
||||
|
||||
verlauf("projekt", projekt.id, "vorlage_angewendet", werIst(p), `${paket}: ${ergebnis.neu} Punkte`);
|
||||
return json(res, {
|
||||
ok: true,
|
||||
neu,
|
||||
uebersprungen: vorlage.length - neu,
|
||||
neu: ergebnis.neu,
|
||||
uebersprungen: ergebnis.uebersprungen,
|
||||
fortschritt: fortschritt(projekt.id),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +53,11 @@ async function darf(req, recht) {
|
||||
kurz genug, dass ein vergessener Link nicht ewig gültig bleibt. */
|
||||
const EINLADUNG_TAGE = 14;
|
||||
|
||||
function einladungAnlegen(kundeId, zweck) {
|
||||
/* Exportiert, weil die Annahme einer Anfrage denselben Einladungslink
|
||||
braucht. Ein zweiter Erzeuger daneben wuerde bedeuten: zwei Stellen,
|
||||
an denen die Gueltigkeitsdauer steht, und irgendwann zwei
|
||||
verschiedene. */
|
||||
export function einladungAnlegen(kundeId, zweck) {
|
||||
const roh = randomBytes(32).toString("base64url");
|
||||
db.prepare(
|
||||
`INSERT INTO wd_kunden_token (token_hash, kunde_id, zweck, laeuft_ab, erstellt_am)
|
||||
@@ -68,7 +72,7 @@ function einladungAnlegen(kundeId, zweck) {
|
||||
return roh;
|
||||
}
|
||||
|
||||
function einladungsLink(roh) {
|
||||
export function einladungsLink(roh) {
|
||||
const basis = process.env.WD_BASIS_URL || "https://dogfather-universe.com";
|
||||
return `${basis}/webdesign/portal.html?einladung=${encodeURIComponent(roh)}`;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
besucherIp, zuVieleVersuche, versuchZaehlen, versucheZuruecksetzen,
|
||||
verlauf, spracheOderDe, centZuText,
|
||||
} from "../lib/webdesign-helfer.js";
|
||||
import { werktageBis } from "../lib/webdesign-termine.js";
|
||||
|
||||
const scrypt = promisify(scryptRoh);
|
||||
|
||||
@@ -267,7 +268,7 @@ export function portalUebersicht(req, res) {
|
||||
.prepare(
|
||||
`SELECT id, nummer, titel, paket, status, naechster_schritt, wartet_auf,
|
||||
preis_cent, anzahlung_cent, anzahlung_bezahlt, rest_bezahlt, waehrung,
|
||||
start_am, richttermin, uebergabe_am, portfolio_freigabe
|
||||
start_am, richttermin, termin_am, angenommen_am, uebergabe_am, portfolio_freigabe
|
||||
FROM wd_projekte WHERE kunde_id = ? AND archiviert = 0
|
||||
ORDER BY erstellt_am DESC`
|
||||
)
|
||||
@@ -285,6 +286,21 @@ export function portalUebersicht(req, res) {
|
||||
p.ungelesene_nachrichten = db
|
||||
.prepare(`SELECT COUNT(*) AS n FROM wd_nachrichten WHERE projekt_id = ? AND intern = 0 AND autor != 'kunde' AND gelesen_kunde = 0`)
|
||||
.get(p.id).n;
|
||||
|
||||
/* Die verbleibende Zeit wird HIER gerechnet, nicht im Browser.
|
||||
|
||||
Der Browser kennt die luxemburgischen Feiertage nicht. Rechnete er
|
||||
selbst, saehe der Kunde eine andere Zahl als ich in der Verwaltung
|
||||
-- bis zu drei Tage daneben. Zwei verschiedene Zahlen fuer denselben
|
||||
Termin sind schlimmer als gar keine.
|
||||
|
||||
Bei uebergebenen Projekten wird nicht mehr gezaehlt: Ein Countdown
|
||||
auf etwas Fertiges wuerde irgendwann "ueberfaellig" anzeigen,
|
||||
obwohl alles erledigt ist. */
|
||||
const laeuftNoch = p.status !== "uebergeben" && p.status !== "abgebrochen";
|
||||
p.verbleibende_werktage = (p.termin_am && laeuftNoch)
|
||||
? werktageBis(p.termin_am, new Date())
|
||||
: null;
|
||||
}
|
||||
|
||||
const abos = db
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/* =====================================================================
|
||||
test-annahme.mjs — Anfrage annehmen und ablehnen
|
||||
|
||||
Das Annehmen ist der folgenreichste Knopf der ganzen Seite: Ein Klick
|
||||
legt einen Kunden an, ein Projekt, eine Aufgabenliste, einen Termin
|
||||
und einen Zugang. Geht dabei etwas schief, entsteht Halbes, das man
|
||||
von Hand wieder auseinanderklauben muss — und beim Kunden ist der
|
||||
Eindruck schon entstanden.
|
||||
|
||||
Deshalb wird hier nicht nur der gute Fall geprüft, sondern vor allem:
|
||||
- der ZWEITE Klick (darf kein zweites Projekt anlegen)
|
||||
- der Abbruch mittendrin (darf NICHTS zurücklassen)
|
||||
- der schon bekannte Kunde (darf keinen Zwilling erzeugen)
|
||||
- der gesperrte Kunde (darf nicht still entsperrt werden)
|
||||
===================================================================== */
|
||||
import { mkdtempSync, rmSync, writeSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomBytes, createHmac } from "node:crypto";
|
||||
|
||||
const L = (...a) => writeSync(1, a.join(" ") + "\n");
|
||||
const tmp = mkdtempSync(join(tmpdir(), "wd-ann-"));
|
||||
process.env.DB_PATH = join(tmp, "t.db");
|
||||
process.env.ENCRYPTION_KEY = randomBytes(32).toString("base64");
|
||||
process.env.WEBDESIGN_API_SECRET = randomBytes(24).toString("hex");
|
||||
process.env.WD_BASIS_URL = "https://dogfather-universe.com";
|
||||
|
||||
const { initDb, db } = await import("./db.js");
|
||||
initDb();
|
||||
const geheim = await import("./lib/webdesign-geheimnisse.js");
|
||||
await geheim.geheimnisseLaden();
|
||||
const { vorlagenSicherstellen } = await import("./lib/webdesign-vorlagen.js");
|
||||
vorlagenSicherstellen();
|
||||
const A = await import("./routes/webdesign-annahme.js");
|
||||
|
||||
let ok = 0, fehler = 0;
|
||||
const pruefe = (n, b, z) => b
|
||||
? (ok++, L(" ok " + n))
|
||||
: (fehler++, L(" FEHL " + n + (z !== undefined ? " -> " + z : "")));
|
||||
|
||||
function ausweis(rolle = "dogfather", gueltigMs = 60_000) {
|
||||
const nutzlast = Buffer.from(JSON.stringify({
|
||||
bereich: "wd-admin", rolle, exp: Date.now() + gueltigMs,
|
||||
})).toString("base64url");
|
||||
const sig = createHmac("sha256", process.env.WEBDESIGN_API_SECRET)
|
||||
.update(nutzlast).digest("base64url");
|
||||
return nutzlast + "." + sig;
|
||||
}
|
||||
const REQ = (extra = {}) => ({
|
||||
headers: { authorization: "Bearer " + ausweis() }, cookies: {},
|
||||
params: {}, body: {}, query: {}, ...extra,
|
||||
});
|
||||
function antwortFaenger() {
|
||||
let out = null;
|
||||
return { res: { status() { return this; }, json(o) { out = o; return this; },
|
||||
setHeader() {}, end() {}, writeHead() { return this; } }, holen: () => out };
|
||||
}
|
||||
async function ruf(fn, extra) {
|
||||
const f = antwortFaenger();
|
||||
await fn(REQ(extra), f.res);
|
||||
return f.holen();
|
||||
}
|
||||
|
||||
const anfrageAnlegen = (id, email, paket = "website", name = "Anna Muster") => {
|
||||
db.prepare(
|
||||
`INSERT INTO wd_anfragen (id, nummer, name, email, telefon, paket, details, status, sprache, erstellt_am)
|
||||
VALUES (?,?,?,?,?,?,?, 'neu', 'de', ?)`
|
||||
).run(id, "A-" + id, name, email, "+352 000", paket, "Bitte modern und schlicht.", new Date().toISOString());
|
||||
};
|
||||
|
||||
L("\n1. VORSCHAU — was passiert, BEVOR etwas passiert?");
|
||||
anfrageAnlegen("a1", "[email protected]");
|
||||
let v = await ruf(A.annahmeVorschau, { params: { id: "a1" } });
|
||||
pruefe("die Vorschau antwortet", v && v.ok === true, JSON.stringify(v).slice(0, 120));
|
||||
pruefe("sie schlaegt einen Titel vor", v.titelVorschlag === "Website für Anna Muster", v.titelVorschlag);
|
||||
pruefe("sie schlaegt einen echten Termin vor", /^\d{4}-\d{2}-\d{2}$/.test(v.terminVorschlag || ""), v.terminVorschlag);
|
||||
pruefe("mit 20 Werktagen fuer eine Website", v.werktage === 20, v.werktage);
|
||||
pruefe("sie kennt die Zahl der Arbeitsschritte", v.schritte > 5, v.schritte);
|
||||
pruefe("sie sagt, dass der Kunde neu ist", v.kundeNeu === true);
|
||||
pruefe("und dass noch nichts angenommen wurde", v.bereitsAngenommen === null);
|
||||
pruefe("die Vorschau hat NICHTS angelegt",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n === 0);
|
||||
|
||||
L("\n2. ANNEHMEN — ein Klick, und alles steht");
|
||||
let r = await ruf(A.anfrageAnnehmen, { params: { id: "a1" } });
|
||||
pruefe("die Annahme gelingt", r && r.ok === true, JSON.stringify(r).slice(0, 200));
|
||||
pruefe("es gibt eine Projektnummer", /^P-/.test(r.projektNummer || ""), r.projektNummer);
|
||||
pruefe("der Kunde wurde angelegt", r.kundeNeu === true);
|
||||
pruefe("es gibt einen Einladungslink", /einladung=/.test(r.einladungsLink || ""), r.einladungsLink);
|
||||
|
||||
const projekt = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(r.projektId);
|
||||
pruefe("das Projekt haengt an der Anfrage", projekt.anfrage_id === "a1");
|
||||
pruefe("es hat einen echten Liefertermin", /^\d{4}-\d{2}-\d{2}$/.test(projekt.termin_am || ""), projekt.termin_am);
|
||||
pruefe("der Zeitpunkt der Annahme ist festgehalten", !!projekt.angenommen_am);
|
||||
pruefe("die zugesagten Werktage sind festgehalten", projekt.termin_werktage === 20, projekt.termin_werktage);
|
||||
/* Der erste Zug liegt beim Kunden -- ohne Unterlagen kann nichts
|
||||
beginnen. Stuende hier "dogfather", saehe der Kunde "wird bearbeitet"
|
||||
und wuesste nicht, dass auf ihn gewartet wird. */
|
||||
pruefe("der erste Zug liegt beim KUNDEN", projekt.wartet_auf === "kunde", projekt.wartet_auf);
|
||||
pruefe("es gibt einen naechsten Schritt im Klartext",
|
||||
/Unterlagen/.test(projekt.naechster_schritt || ""), projekt.naechster_schritt);
|
||||
|
||||
const aufgaben = db.prepare(`SELECT COUNT(*) AS n FROM wd_aufgaben WHERE projekt_id = ?`).get(r.projektId);
|
||||
pruefe("die Aufgabenliste aus der Vorlage steht", aufgaben.n > 5, aufgaben.n);
|
||||
pruefe("und die Antwort nennt dieselbe Zahl", r.schritte === aufgaben.n, r.schritte + " vs. " + aufgaben.n);
|
||||
|
||||
const kunde = db.prepare(`SELECT * FROM wd_kunden WHERE id = ?`).get(r.kundeId);
|
||||
pruefe("der Kunde ist freigeschaltet", kunde.freigeschaltet === 1);
|
||||
pruefe("seine Sprache wurde uebernommen", kunde.sprache === "de", kunde.sprache);
|
||||
pruefe("seine Telefonnummer wurde uebernommen", !!kunde.telefon);
|
||||
|
||||
const anf = db.prepare(`SELECT * FROM wd_anfragen WHERE id = 'a1'`).get();
|
||||
pruefe("die Anfrage steht auf angenommen", anf.status === "angenommen", anf.status);
|
||||
pruefe("und ist mit dem Kunden verknuepft", anf.kunde_id === r.kundeId);
|
||||
|
||||
L("\n3. DER ZWEITE KLICK — darf nichts doppelt anlegen");
|
||||
const vorher = db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n;
|
||||
const r2 = await ruf(A.anfrageAnnehmen, { params: { id: "a1" } });
|
||||
pruefe("der zweite Klick meldet KEINEN Fehler", r2 && r2.ok === true, JSON.stringify(r2).slice(0, 120));
|
||||
pruefe("er sagt, dass schon angenommen war", r2.bereitsAngenommen === true);
|
||||
pruefe("er liefert dieselbe Projektnummer", r2.projektNummer === r.projektNummer);
|
||||
pruefe("und es entstand KEIN zweites Projekt",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n === vorher,
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n + " statt " + vorher);
|
||||
pruefe("auch keine zweite Aufgabenliste",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_aufgaben WHERE projekt_id = ?`).get(r.projektId).n === aufgaben.n);
|
||||
|
||||
L("\n4. EIN KUNDE, DER SCHON DA IST — kein Zwilling");
|
||||
/* Bewusst mit anderer Gross-/Kleinschreibung: "Anna@" und "anna@" sind
|
||||
derselbe Mensch. Zwei Kundeneintraege fuer eine Person sind spaeter
|
||||
kaum wieder zu trennen -- Nachrichten und Projekte haengen dann an
|
||||
verschiedenen Zugaengen. */
|
||||
anfrageAnlegen("a2", "[email protected]", "onepager");
|
||||
const kundenVorher = db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden`).get().n;
|
||||
const r3 = await ruf(A.anfrageAnnehmen, { params: { id: "a2" } });
|
||||
pruefe("die zweite Anfrage wird angenommen", r3 && r3.ok === true, JSON.stringify(r3).slice(0, 120));
|
||||
pruefe("KEIN zweiter Kundeneintrag trotz anderer Schreibweise",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden`).get().n === kundenVorher,
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden`).get().n);
|
||||
pruefe("es ist derselbe Kunde", r3.kundeId === r.kundeId);
|
||||
pruefe("kein neuer Einladungslink -- er hat ja schon einen Zugang",
|
||||
r3.einladungsLink === null, String(r3.einladungsLink));
|
||||
pruefe("aber ein eigenes, zweites Projekt", r3.projektId !== r.projektId);
|
||||
pruefe("Onepager bekommt 10 Werktage",
|
||||
db.prepare(`SELECT termin_werktage AS w FROM wd_projekte WHERE id = ?`).get(r3.projektId).w === 10);
|
||||
|
||||
L("\n5. EIN GESPERRTER KUNDE DARF NICHT STILL ENTSPERRT WERDEN");
|
||||
db.prepare(`UPDATE wd_kunden SET gesperrt = 1, freigeschaltet = 0 WHERE id = ?`).run(r.kundeId);
|
||||
anfrageAnlegen("a3", "[email protected]", "shop");
|
||||
await ruf(A.anfrageAnnehmen, { params: { id: "a3" } });
|
||||
const nachher = db.prepare(`SELECT gesperrt, freigeschaltet FROM wd_kunden WHERE id = ?`).get(r.kundeId);
|
||||
pruefe("er bleibt gesperrt", nachher.gesperrt === 1, nachher.gesperrt);
|
||||
pruefe("und wird nicht nebenbei freigeschaltet", nachher.freigeschaltet === 0, nachher.freigeschaltet);
|
||||
db.prepare(`UPDATE wd_kunden SET gesperrt = 0, freigeschaltet = 1 WHERE id = ?`).run(r.kundeId);
|
||||
|
||||
L("\n6. EIGENER TERMIN STATT VORSCHLAG");
|
||||
anfrageAnlegen("a4", "[email protected]", "website", "Bert Beispiel");
|
||||
const r4 = await ruf(A.anfrageAnnehmen, { params: { id: "a4" }, body: { termin: "2026-12-24" } });
|
||||
pruefe("ein eigener Termin wird uebernommen",
|
||||
db.prepare(`SELECT termin_am AS t FROM wd_projekte WHERE id = ?`).get(r4.projektId).t === "2026-12-24");
|
||||
anfrageAnlegen("a5", "[email protected]", "website", "Carla Muster");
|
||||
const r5 = await ruf(A.anfrageAnnehmen, { params: { id: "a5" }, body: { termin: "31.13.2026" } });
|
||||
pruefe("ein unmoegliches Datum wird abgewiesen", r5.ok === false, JSON.stringify(r5).slice(0, 120));
|
||||
pruefe("und dabei entsteht NICHTS",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte WHERE anfrage_id = 'a5'`).get().n === 0);
|
||||
|
||||
L("\n7. LAUFENDE BETREUUNG — kein Liefertermin, kein Countdown");
|
||||
anfrageAnlegen("a6", "[email protected]", "betreuung", "Dora Muster");
|
||||
const r6 = await ruf(A.anfrageAnnehmen, { params: { id: "a6" } });
|
||||
pruefe("sie wird angenommen", r6.ok === true);
|
||||
pruefe("aber ohne Liefertermin",
|
||||
db.prepare(`SELECT termin_am AS t FROM wd_projekte WHERE id = ?`).get(r6.projektId).t === null);
|
||||
|
||||
L("\n8. OHNE E-MAIL GEHT ES NICHT — und es bleibt nichts liegen");
|
||||
db.prepare(
|
||||
`INSERT INTO wd_anfragen (id, nummer, name, email, paket, status, sprache, erstellt_am)
|
||||
VALUES ('a7','A-a7','Ohne Mail','', 'website', 'neu','de',?)`
|
||||
).run(new Date().toISOString());
|
||||
const r7 = await ruf(A.anfrageAnnehmen, { params: { id: "a7" } });
|
||||
pruefe("die Annahme wird abgelehnt", r7.ok === false, JSON.stringify(r7).slice(0, 140));
|
||||
pruefe("und es entstand kein Kunde ohne Adresse",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden WHERE email = ''`).get().n === 0);
|
||||
|
||||
L("\n9. ABBRUCH MITTENDRIN — es darf NICHTS zurueckbleiben");
|
||||
/* Der wichtigste Fall. Geprueft wird, indem der Vorlagenschritt zum
|
||||
Scheitern gebracht wird: Die Tabelle wd_aufgaben bekommt kurz einen
|
||||
Ausloeser, der jedes Einfuegen verweigert. Ohne Transaktion blieben
|
||||
Kunde und Projekt stehen -- ein Zugang, hinter dem eine leere Seite
|
||||
wartet. */
|
||||
db.exec(`CREATE TRIGGER pruef_stopp BEFORE INSERT ON wd_aufgaben
|
||||
BEGIN SELECT RAISE(ABORT, 'absichtlicher Testfehler'); END;`);
|
||||
anfrageAnlegen("a8", "[email protected]", "website", "Emil Muster");
|
||||
const kundenVorAbbruch = db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden`).get().n;
|
||||
const projekteVorAbbruch = db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n;
|
||||
/* Vorher zaehlen statt eine feste Zahl zu erwarten: Bis hierher wurden
|
||||
drei Kunden voellig zurecht angelegt, also gibt es drei Zugaenge. Die
|
||||
erste Fassung dieser Pruefung verlangte "hoechstens einer" und schlug
|
||||
deshalb fehl, obwohl alles stimmte -- eine feste Erwartung an einen
|
||||
mitwachsenden Bestand ist immer falsch. */
|
||||
const zugaengeVorAbbruch = db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden_token`).get().n;
|
||||
const r8 = await ruf(A.anfrageAnnehmen, { params: { id: "a8" } });
|
||||
db.exec(`DROP TRIGGER pruef_stopp;`);
|
||||
|
||||
pruefe("der Abbruch wird gemeldet", r8 && r8.ok === false, JSON.stringify(r8).slice(0, 160));
|
||||
pruefe("KEIN Kunde blieb zurueck",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden`).get().n === kundenVorAbbruch,
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden`).get().n + " statt " + kundenVorAbbruch);
|
||||
pruefe("KEIN Projekt blieb zurueck",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_projekte`).get().n === projekteVorAbbruch);
|
||||
pruefe("die Anfrage steht NICHT faelschlich auf angenommen",
|
||||
db.prepare(`SELECT status FROM wd_anfragen WHERE id = 'a8'`).get().status === "neu");
|
||||
pruefe("und der Abbruch hinterliess KEINEN neuen Zugang",
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden_token`).get().n === zugaengeVorAbbruch,
|
||||
db.prepare(`SELECT COUNT(*) AS n FROM wd_kunden_token`).get().n + " statt " + zugaengeVorAbbruch);
|
||||
|
||||
L("\n10. ABLEHNEN");
|
||||
anfrageAnlegen("b1", "[email protected]", "shop", "Frank Muster");
|
||||
const vorschau = await ruf(A.absageVorschau, { params: { id: "b1" }, query: { grund: "budget" } });
|
||||
pruefe("die Absage-Vorschau antwortet", vorschau.ok === true);
|
||||
pruefe("sie nennt den Kunden beim Namen", /Frank Muster/.test(vorschau.text), vorschau.text.slice(0, 60));
|
||||
pruefe("sie nennt den Grund im Klartext", /Budget/.test(vorschau.text));
|
||||
pruefe("die Vorschau veraendert NICHTS",
|
||||
db.prepare(`SELECT status FROM wd_anfragen WHERE id = 'b1'`).get().status === "neu");
|
||||
|
||||
const ab = await ruf(A.anfrageAblehnen, { params: { id: "b1" }, body: { grund: "budget", senden: false } });
|
||||
pruefe("das Ablehnen gelingt", ab.ok === true, JSON.stringify(ab).slice(0, 120));
|
||||
const b1 = db.prepare(`SELECT * FROM wd_anfragen WHERE id = 'b1'`).get();
|
||||
pruefe("der Status steht auf abgelehnt", b1.status === "abgelehnt");
|
||||
pruefe("der Grund ist auswertbar hinterlegt", b1.ablehnung_grund === "budget", b1.ablehnung_grund);
|
||||
pruefe("ohne Senden ist nichts verschickt", b1.absage_gesendet_am === null);
|
||||
|
||||
L("\n11. DER TEXT KOMMT IN DER SPRACHE DES KUNDEN");
|
||||
for (const [sp, wort] of [["fr", "Bonjour"], ["pt", "Olá"], ["en", "Hello"], ["de-CH", "Grüsse"]]) {
|
||||
const t = A.absageText(sp, "kein_zeitfenster", "Test");
|
||||
pruefe("Absage auf " + sp, t.includes(wort), t.split("\n")[0]);
|
||||
}
|
||||
pruefe("eine unbekannte Sprache faellt auf Deutsch zurueck",
|
||||
A.absageText("kli", "budget", "Test").includes("Hallo"));
|
||||
pruefe("ein unbekannter Grund ergibt trotzdem einen vollstaendigen Text",
|
||||
A.absageText("de", "gibtesnicht", "Test").includes("Dogfather Webdesign"));
|
||||
|
||||
L("\n12. EIN LAUFENDES PROJEKT KANN MAN NICHT NACHTRAEGLICH ABLEHNEN");
|
||||
const abZuSpaet = await ruf(A.anfrageAblehnen, { params: { id: "a1" }, body: { grund: "budget" } });
|
||||
pruefe("das wird verweigert", abZuSpaet.ok === false, JSON.stringify(abZuSpaet).slice(0, 140));
|
||||
pruefe("mit Hinweis auf das Projekt", /P-/.test(abZuSpaet.error || ""), abZuSpaet.error);
|
||||
pruefe("und die Anfrage bleibt angenommen",
|
||||
db.prepare(`SELECT status FROM wd_anfragen WHERE id = 'a1'`).get().status === "angenommen");
|
||||
|
||||
L("\n13. DIE RECHTEPRUEFUNG GREIFT");
|
||||
for (const [name, kopf] of [
|
||||
["ohne Ausweis kein Annehmen", {}],
|
||||
["mit gefaelschter Unterschrift kein Annehmen",
|
||||
{ authorization: "Bearer " + ausweis().split(".")[0] + ".XXXX" }],
|
||||
["mit abgelaufenem Ausweis kein Annehmen",
|
||||
{ authorization: "Bearer " + ausweis("dogfather", -1000) }],
|
||||
]) {
|
||||
const f = antwortFaenger();
|
||||
await A.anfrageAnnehmen({ headers: kopf, cookies: {}, params: { id: "b1" }, body: {} }, f.res);
|
||||
pruefe(name, f.holen() && f.holen().ok === false, JSON.stringify(f.holen()).slice(0, 80));
|
||||
}
|
||||
|
||||
L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
|
||||
process.exitCode = fehler ? 1 : 0;
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
@@ -0,0 +1,138 @@
|
||||
/* =====================================================================
|
||||
test-termine.mjs — die Terminrechnung
|
||||
|
||||
Diese Datei rechnet die Zahl aus, die der Kunde als Zusage liest.
|
||||
Sie hat keine Oberfläche, an der ein Fehler auffiele: Ein Termin, der
|
||||
drei Tage zu früh liegt, sieht genauso aus wie ein richtiger. Deshalb
|
||||
wird hier gegen NACHPRÜFBARE Daten geprüft — Osterdaten und
|
||||
Wochentage kann jeder in einem Kalender nachschlagen.
|
||||
|
||||
Läuft ohne Datenbank, also auch auf dem Windows-Rechner.
|
||||
===================================================================== */
|
||||
import {
|
||||
terminVorschlag, werktageAddieren, werktageBis, istWerktag,
|
||||
feiertage, hatLiefertermin, laufzeitFuer, LAUFZEIT_WERKTAGE,
|
||||
} from "./lib/webdesign-termine.js";
|
||||
|
||||
let ok = 0, fehler = 0;
|
||||
const pruefe = (n, b, z) => b
|
||||
? (ok++, console.log(" ok " + n))
|
||||
: (fehler++, console.log(" FEHL " + n + (z !== undefined ? " -> " + z : "")));
|
||||
|
||||
console.log("\n1. OSTERN — gegen den Kalender nachgeschlagen");
|
||||
/* Wenn diese Formel falsch ist, sind Ostermontag, Christi Himmelfahrt
|
||||
und Pfingstmontag ALLE falsch. Deshalb zuerst und über mehrere Jahre. */
|
||||
const OSTERN = {
|
||||
2024: "2024-03-31", 2025: "2025-04-20", 2026: "2026-04-05",
|
||||
2027: "2027-03-28", 2028: "2028-04-16", 2030: "2030-04-21",
|
||||
};
|
||||
for (const [jahr, datum] of Object.entries(OSTERN)) {
|
||||
/* Ostersonntag selbst ist kein Eintrag in der Feiertagsliste (er ist
|
||||
ein Sonntag). Geprüft wird deshalb der Ostermontag: einen Tag
|
||||
später. */
|
||||
const montag = new Date(new Date(datum + "T00:00:00Z").getTime() + 864e5)
|
||||
.toISOString().slice(0, 10);
|
||||
pruefe("Ostermontag " + jahr + " ist der " + montag,
|
||||
feiertage(Number(jahr)).has(montag),
|
||||
Array.from(feiertage(Number(jahr))).sort().join(" "));
|
||||
}
|
||||
|
||||
console.log("\n2. DIE FESTEN FEIERTAGE LUXEMBURGS");
|
||||
const f26 = feiertage(2026);
|
||||
for (const [name, tag] of [
|
||||
["Neujahr", "2026-01-01"], ["Tag der Arbeit", "2026-05-01"],
|
||||
["Europatag", "2026-05-09"], ["Nationalfeiertag", "2026-06-23"],
|
||||
["Mariä Himmelfahrt", "2026-08-15"], ["Allerheiligen", "2026-11-01"],
|
||||
["Weihnachten", "2026-12-25"], ["Zweiter Weihnachtstag", "2026-12-26"],
|
||||
]) pruefe(name + " (" + tag + ")", f26.has(tag));
|
||||
|
||||
pruefe("Christi Himmelfahrt 2026 ist der 14.05.", f26.has("2026-05-14"));
|
||||
pruefe("Pfingstmontag 2026 ist der 25.05.", f26.has("2026-05-25"));
|
||||
pruefe("2026 hat elf Feiertage", f26.size === 11, f26.size);
|
||||
|
||||
console.log("\n3. WAS IST EIN WERKTAG?");
|
||||
pruefe("Montag ist einer", istWerktag(new Date("2026-08-24T00:00:00Z")));
|
||||
pruefe("Samstag nicht", !istWerktag(new Date("2026-08-22T00:00:00Z")));
|
||||
pruefe("Sonntag nicht", !istWerktag(new Date("2026-08-23T00:00:00Z")));
|
||||
pruefe("der Nationalfeiertag nicht, obwohl er ein Dienstag ist",
|
||||
!istWerktag(new Date("2026-06-23T00:00:00Z")));
|
||||
|
||||
console.log("\n4. WERKTAGE ADDIEREN");
|
||||
/* Der 21.08.2026 ist ein Freitag. Ein Werktag weiter ist Montag, nicht
|
||||
Samstag -- genau der Fehler, den eine Kalendertagsrechnung macht. */
|
||||
pruefe("Freitag + 1 Werktag = Montag",
|
||||
werktageAddieren("2026-08-21", 1) === "2026-08-24",
|
||||
werktageAddieren("2026-08-21", 1));
|
||||
pruefe("Freitag + 5 Werktage = Freitag darauf",
|
||||
werktageAddieren("2026-08-21", 5) === "2026-08-28",
|
||||
werktageAddieren("2026-08-21", 5));
|
||||
/* Über Feiertage hinweg: Vom 19.06.2026 (Freitag) sind fünf Werktage
|
||||
normalerweise der 26.06. Der 23.06. ist aber Nationalfeiertag, also
|
||||
verschiebt sich alles um einen Tag auf den 29.06. (Montag). */
|
||||
pruefe("ein Feiertag dazwischen verschiebt den Termin",
|
||||
werktageAddieren("2026-06-19", 5) === "2026-06-29",
|
||||
werktageAddieren("2026-06-19", 5) + " (ohne Feiertag waere es der 26.06.)");
|
||||
pruefe("der Starttag selbst zaehlt nicht mit",
|
||||
werktageAddieren("2026-08-24", 1) === "2026-08-25",
|
||||
werktageAddieren("2026-08-24", 1));
|
||||
pruefe("null Werktage ergeben keinen Termin", werktageAddieren("2026-08-24", 0) === null);
|
||||
pruefe("Unsinn ergibt keinen Termin", werktageAddieren("2026-08-24", "viele") === null);
|
||||
|
||||
console.log("\n5. DIE UHRZEIT DARF DAS ERGEBNIS NICHT VERSCHIEBEN");
|
||||
/* Wer morgens annimmt, muss denselben Termin bekommen wie jemand, der
|
||||
abends annimmt. Ohne Normierung auf Mitternacht springt das Datum. */
|
||||
const frueh = werktageAddieren("2026-08-24T06:00:00Z", 10);
|
||||
const spaet = werktageAddieren("2026-08-24T23:30:00Z", 10);
|
||||
pruefe("frueh und spaet am selben Tag ergeben denselben Termin",
|
||||
frueh === spaet, frueh + " vs. " + spaet);
|
||||
|
||||
console.log("\n6. VERBLEIBENDE ZEIT");
|
||||
pruefe("heute ist null",
|
||||
werktageBis("2026-08-24", "2026-08-24T10:00:00Z") === 0,
|
||||
werktageBis("2026-08-24", "2026-08-24T10:00:00Z"));
|
||||
pruefe("Montag bis Freitag sind vier Werktage",
|
||||
werktageBis("2026-08-28", "2026-08-24T10:00:00Z") === 4,
|
||||
werktageBis("2026-08-28", "2026-08-24T10:00:00Z"));
|
||||
/* Das Wochenende zaehlt nicht mit: Von Freitag bis Montag ist genau
|
||||
EIN Werktag, nicht drei Tage. */
|
||||
pruefe("Freitag bis Montag ist ein Werktag",
|
||||
werktageBis("2026-08-24", "2026-08-21T10:00:00Z") === 1,
|
||||
werktageBis("2026-08-24", "2026-08-21T10:00:00Z"));
|
||||
pruefe("ein ueberschrittener Termin ergibt eine negative Zahl",
|
||||
werktageBis("2026-08-20", "2026-08-24T10:00:00Z") === -2,
|
||||
werktageBis("2026-08-20", "2026-08-24T10:00:00Z"));
|
||||
pruefe("ohne Termin kommt nichts heraus", werktageBis(null) === null);
|
||||
|
||||
console.log("\n7. VORSCHLAG JE PAKET");
|
||||
const v = terminVorschlag("website", "2026-08-24");
|
||||
pruefe("Website bekommt 20 Werktage", v.werktage === 20, v.werktage);
|
||||
pruefe("und daraus ein echtes Datum", /^\d{4}-\d{2}-\d{2}$/.test(v.termin || ""), v.termin);
|
||||
pruefe("das Datum liegt auf einem Werktag",
|
||||
istWerktag(new Date(v.termin + "T00:00:00Z")), v.termin);
|
||||
pruefe("20 Werktage sind rund vier Wochen, nicht 20 Tage",
|
||||
(new Date(v.termin) - new Date("2026-08-24")) / 864e5 >= 26,
|
||||
Math.round((new Date(v.termin) - new Date("2026-08-24")) / 864e5) + " Kalendertage");
|
||||
|
||||
const b = terminVorschlag("betreuung", "2026-08-24");
|
||||
pruefe("eine laufende Betreuung bekommt KEINEN Liefertermin", b.termin === null && b.laufend);
|
||||
pruefe("und wird auch so gekennzeichnet", hatLiefertermin("betreuung") === false);
|
||||
|
||||
const u = terminVorschlag("gibtesnicht", "2026-08-24");
|
||||
pruefe("ein unbekanntes Paket faellt auf den mittleren Wert zurueck",
|
||||
u.werktage === LAUFZEIT_WERKTAGE.unklar, u.werktage);
|
||||
pruefe("und bekommt trotzdem ein Datum -- kein stilles Projekt ohne Zusage",
|
||||
!!u.termin && u.laufend === false, String(u.termin));
|
||||
pruefe("nur die laufende Betreuung ergibt null", laufzeitFuer("betreuung") === 0);
|
||||
pruefe("ein Tippfehler im Paketnamen ergibt NICHT null",
|
||||
laufzeitFuer("websiet") > 0, laufzeitFuer("websiet"));
|
||||
|
||||
console.log("\n8. JEDES PAKET IST HINTERLEGT");
|
||||
/* Fehlt eines, bekaeme es beim Annehmen still den Ersatzwert -- und
|
||||
niemand merkte, dass die Zusage geraten war. */
|
||||
for (const p of ["onepager", "website", "shop", "verwaltung", "betreuung", "unklar"]) {
|
||||
pruefe("Laufzeit fuer " + p + " ist hinterlegt",
|
||||
Object.prototype.hasOwnProperty.call(LAUFZEIT_WERKTAGE, p), LAUFZEIT_WERKTAGE[p]);
|
||||
}
|
||||
|
||||
console.log(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
|
||||
process.exitCode = fehler ? 1 : 0;
|
||||
@@ -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
@@ -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
@@ -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