diff --git a/assets/js/i18n-wd-portal.js b/assets/js/i18n-wd-portal.js index 9b2ef314..50448717 100644 --- a/assets/js/i18n-wd-portal.js +++ b/assets/js/i18n-wd-portal.js @@ -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" }, diff --git a/assets/js/wd-core.js b/assets/js/wd-core.js index 3de353c5..510a6c6a 100644 --- a/assets/js/wd-core.js +++ b/assets/js/wd-core.js @@ -740,8 +740,35 @@ ""; 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 */ } }); diff --git a/assets/js/wd-portal.js b/assets/js/wd-portal.js index b91f441e..77b10c9c 100644 --- a/assets/js/wd-portal.js +++ b/assets/js/wd-portal.js @@ -138,6 +138,71 @@ (jetzt + 1) + "/" + PHASEN.length + ""; } + /* --------------------------------------------------------------------- + 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( + '
' + + '' + s(window.WD.t("po_liefertermin")) + ": " + + s(datumKurz(p.termin_am)) + + (rest ? ' ' + s(rest.text) + "" : "") + + "
" + ); + } + + /* 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( + '' + + s(window.WD.t("po_richttermin")) + ": " + s(p.richttermin) + "
" + ); + } + return stuecke.join(""); + } + function wartetMarke(wartet) { if (wartet !== "kunde" && wartet !== "dogfather") return ""; return '' + @@ -361,10 +426,7 @@ ? '' + s(window.WD.t("po_naechster_schritt")) + ": " + s(p.naechster_schritt) + "
" : "") + - (p.richttermin - ? '' + s(window.WD.t("po_richttermin")) + - ": " + window.WD.datum(p.richttermin) + "
" - : "") + + terminZeile(p) + (p.offene_aenderungen || p.ungelesene_nachrichten ? '' + (p.ungelesene_nachrichten ? p.ungelesene_nachrichten + " " + s(window.WD.t("po_nachrichten")) : "") + diff --git a/cloudflare-worker/migrations/0015_webdesign_annahme.sql b/cloudflare-worker/migrations/0015_webdesign_annahme.sql new file mode 100644 index 00000000..c7a4f826 --- /dev/null +++ b/cloudflare-worker/migrations/0015_webdesign_annahme.sql @@ -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); diff --git a/pruef-annahme.mjs b/pruef-annahme.mjs new file mode 100644 index 00000000..a0db7674 --- /dev/null +++ b/pruef-annahme.mjs @@ -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: "anna@example.com", + 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; diff --git a/pruef-portal.mjs b/pruef-portal.mjs index 2acca249..0411f051 100644 --- a/pruef-portal.mjs +++ b/pruef-portal.mjs @@ -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 }); diff --git a/server-internal/db.js b/server-internal/db.js index 27fc94f8..2cece8eb 100644 --- a/server-internal/db.js +++ b/server-internal/db.js @@ -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) { diff --git a/server-internal/index.js b/server-internal/index.js index 7ae9a542..545aa3c7 100644 --- a/server-internal/index.js +++ b/server-internal/index.js @@ -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. */ diff --git a/server-internal/lib/webdesign-termine.js b/server-internal/lib/webdesign-termine.js new file mode 100644 index 00000000..69661d4e --- /dev/null +++ b/server-internal/lib/webdesign-termine.js @@ -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, + }; +} diff --git a/server-internal/routes/webdesign-annahme.js b/server-internal/routes/webdesign-annahme.js new file mode 100644 index 00000000..002b181b --- /dev/null +++ b/server-internal/routes/webdesign-annahme.js @@ -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, + }); +} diff --git a/server-internal/routes/webdesign-aufgaben.js b/server-internal/routes/webdesign-aufgaben.js index 469b6d67..3e733c15 100644 --- a/server-internal/routes/webdesign-aufgaben.js +++ b/server-internal/routes/webdesign-aufgaben.js @@ -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), }); } diff --git a/server-internal/routes/webdesign-kunden.js b/server-internal/routes/webdesign-kunden.js index 5afc5a9b..3927edce 100644 --- a/server-internal/routes/webdesign-kunden.js +++ b/server-internal/routes/webdesign-kunden.js @@ -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)}`; } diff --git a/server-internal/routes/webdesign-portal.js b/server-internal/routes/webdesign-portal.js index 0398f1d0..5d316376 100644 --- a/server-internal/routes/webdesign-portal.js +++ b/server-internal/routes/webdesign-portal.js @@ -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 diff --git a/server-internal/test-annahme.mjs b/server-internal/test-annahme.mjs new file mode 100644 index 00000000..b46d0b53 --- /dev/null +++ b/server-internal/test-annahme.mjs @@ -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", "anna@example.com"); +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", "ANNA@example.com", "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", "anna@example.com", "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", "bert@example.com", "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", "carla@example.com", "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", "dora@example.com", "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", "emil@example.com", "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", "frank@example.com", "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 }); diff --git a/server-internal/test-termine.mjs b/server-internal/test-termine.mjs new file mode 100644 index 00000000..3cd8241b --- /dev/null +++ b/server-internal/test-termine.mjs @@ -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; diff --git a/webdesign/portal.html b/webdesign/portal.html index 0a7cf24b..86245ff7 100644 --- a/webdesign/portal.html +++ b/webdesign/portal.html @@ -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; diff --git a/webdesign/sw.js b/webdesign/sw.js index 8dd77c9e..db26a159 100644 --- a/webdesign/sw.js +++ b/webdesign/sw.js @@ -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. */ diff --git a/webdesign/verwaltung.html b/webdesign/verwaltung.html index 2ec5079c..c012c9f8 100644 --- a/webdesign/verwaltung.html +++ b/webdesign/verwaltung.html @@ -1873,22 +1873,74 @@ (a.kunde_id ? '
Aus dieser Anfrage wurde bereits ein Kundenzugang erstellt. Du findest ihn oben unter „Kunden“.
Legt in einem Schritt den Kundenzugang UND das Projekt an — mit den Angaben aus dieser Anfrage. Du bekommst danach sofort den Einladungslink.
" + - '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.
" + + '