diff --git a/pruef-suche.mjs b/pruef-suche.mjs new file mode 100644 index 00000000..1c01ea79 --- /dev/null +++ b/pruef-suche.mjs @@ -0,0 +1,290 @@ +/* ===================================================================== + pruef-suche.mjs — die Suche über alles + + Geprüft wird gegen das ARIA-Muster "Combobox mit Listbox-Popup" aus den + W3C Authoring Practices, nicht gegen mein Gefühl. Die Vorgaben dort + sind konkret: role=combobox am EINGABEFELD, aria-expanded, aria-controls, + aria-activedescendant auf die ausgewählte Option, role=listbox am + Popup, role=option mit aria-selected an den Einträgen. + + Warum das hier wirklich zählt: Ohne diese Auszeichnung ist ein Feld, + das Vorschläge einblendet, für einen Screenreader stumm. Man tippt, es + erscheinen Treffer, angesagt wird nichts. Das sieht man beim Testen + mit den Augen nie. + + Zusätzlich die drei Zustände, die man beim Bauen gern vergisst: leer, + lädt, Fehler. Ein Kasten, der bei einem Serverfehler leer bleibt, + sieht aus wie "nichts gefunden" — und man sucht das Problem an der + falschen Stelle. + ===================================================================== */ +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(4353, r)); + +const ergebnisse = []; +function pruefe(name, ok, zusatz) { + ergebnisse.push({ name, ok }); + console.log((ok ? " ok " : " FEHL ") + name + (zusatz !== undefined ? " -> " + zusatz : "")); +} + +const TREFFER = [ + { art: "anfrage", id: "a1", titel: "Anna Muster", unten: "A-2608-0001 · anna@example.com", marke: "neu", ziel: "anfragen" }, + { art: "kunde", id: "k1", titel: "Anna Muster", unten: "Musterbau GmbH · anna@example.com", marke: "aktiv", ziel: "kunden" }, + { art: "projekt", id: "p1", titel: "Website für Musterbau", unten: "P-2608-0001 · Anna Muster", marke: "design", ziel: "projekte" }, + { art: "zahlung", id: "z1", titel: "Anzahlung Website", unten: "Z-2608-0001 · Anna Muster · 300,00 €", marke: "offen", ziel: "zahlungen" }, +]; + +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 abfragen = [], langsamMachen = false, fehlerWerfen = false; + + await seite.route("**/webdesign/admin/**", async (route) => { + const u = new URL(route.request().url()); + if (u.pathname.endsWith("/admin/suche")) { + const q = u.searchParams.get("q") || ""; + abfragen.push(q); + if (fehlerWerfen) return route.fulfill({ status: 500, json: { ok: false, error: "Serverfehler" } }); + if (langsamMachen) await new Promise((r) => setTimeout(r, 600)); + if (q.trim().length < 2) return route.fulfill({ json: { ok: true, begriff: q, zuKurz: true, treffer: [], anzahl: 0 } }); + if (/nix/.test(q)) return route.fulfill({ json: { ok: true, begriff: q, zuKurz: false, treffer: [], anzahl: 0 } }); + return route.fulfill({ json: { ok: true, begriff: q, zuKurz: false, treffer: TREFFER, + anzahl: TREFFER.length, begrenzt: false, grenzeJeBereich: 6 } }); + } + if (u.pathname.endsWith("/admin/uebersicht")) { + return route.fulfill({ json: { ok: true, + beiMir: { anfragen: { n: 0 }, 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: {} } }); + } + return route.fulfill({ json: { ok: true, anfragen: [], 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:4353/webdesign/verwaltung.html"); + await seite.waitForTimeout(700); + console.log("\n=== " + wo + " ==="); + + /* ---- 1. Öffnen ---- */ + pruefe(wo + ": die Palette ist zunaechst zu", + !(await seite.locator("#vw-palette").isVisible())); + await seite.click("#vw-suche-auf"); + await seite.waitForTimeout(300); + pruefe(wo + ": der Lupenknopf oeffnet sie", await seite.locator("#vw-palette").isVisible()); + pruefe(wo + ": der Fokus steht sofort im Suchfeld", + await seite.evaluate(() => document.activeElement.id === "vw-pal-feld"), + await seite.evaluate(() => document.activeElement.id)); + + /* ---- 2. ARIA im Ruhezustand ---- */ + const ruhe = await seite.evaluate(() => { + const f = document.getElementById("vw-pal-feld"); + return { rolle: f.getAttribute("role"), auf: f.getAttribute("aria-expanded"), + steuert: f.getAttribute("aria-controls"), auto: f.getAttribute("aria-autocomplete"), + listenRolle: document.getElementById("vw-pal-liste").getAttribute("role") }; + }); + pruefe(wo + ": das Eingabefeld traegt role=combobox", ruhe.rolle === "combobox", ruhe.rolle); + pruefe(wo + ": aria-expanded ist ohne Treffer false", ruhe.auf === "false", ruhe.auf); + pruefe(wo + ": aria-controls zeigt auf die Liste", ruhe.steuert === "vw-pal-liste", ruhe.steuert); + pruefe(wo + ": aria-autocomplete=list ist gesetzt", ruhe.auto === "list", ruhe.auto); + pruefe(wo + ": die Liste traegt role=listbox", ruhe.listenRolle === "listbox", ruhe.listenRolle); + + /* ---- 3. Zu kurz ist ein eigener Zustand ---- */ + await seite.fill("#vw-pal-feld", "a"); + await seite.waitForTimeout(450); + pruefe(wo + ": ein einzelner Buchstabe zeigt einen Hinweis, keine leere Liste", + /Mindestens zwei/.test(await seite.locator("#vw-pal-liste").innerText()), + await seite.locator("#vw-pal-liste").innerText()); + + /* ---- 4. Suchen und ARIA bei Treffern ---- */ + abfragen = []; + await seite.fill("#vw-pal-feld", "Muster"); + await seite.waitForTimeout(500); + const treffer = await seite.locator('#vw-pal-liste [role="option"]').count(); + pruefe(wo + ": alle vier Treffer erscheinen", treffer === 4, treffer); + pruefe(wo + ": jede Art ist erkennbar", + (await seite.locator("#vw-pal-liste").innerText()).includes("ANFRAGE") + || (await seite.locator("#vw-pal-liste").innerText()).toLowerCase().includes("anfrage"), + (await seite.locator("#vw-pal-liste").innerText()).slice(0, 60)); + + const mitTreffern = await seite.evaluate(() => { + const f = document.getElementById("vw-pal-feld"); + const erste = document.querySelector('#vw-pal-liste [role="option"]'); + return { auf: f.getAttribute("aria-expanded"), aktiv: f.getAttribute("aria-activedescendant"), + ersteId: erste.id, gewaehlt: erste.getAttribute("aria-selected") }; + }); + pruefe(wo + ": aria-expanded steht jetzt auf true", mitTreffern.auf === "true", mitTreffern.auf); + pruefe(wo + ": aria-activedescendant zeigt auf den ersten Treffer", + mitTreffern.aktiv === mitTreffern.ersteId, mitTreffern.aktiv + " vs. " + mitTreffern.ersteId); + pruefe(wo + ": der erste Treffer ist aria-selected", mitTreffern.gewaehlt === "true", mitTreffern.gewaehlt); + pruefe(wo + ": die Trefferzahl wird angesagt (role=status)", + /4 Treffer/.test(await seite.locator("#vw-pal-zahl").innerText()), + await seite.locator("#vw-pal-zahl").innerText()); + + /* ---- 4b. Groessenverhaeltnisse ---- + Der Tastaturhinweis unten darf nicht so gross sein wie der Inhalt, + den er erklaert. Genau das passierte, solange die Regel .wd p die + eigene ueberstimmte: 17,9px statt 11,5px. Dieselbe Falle wie schon + dreimal zuvor in diesem Projekt, deshalb hier festgenagelt. */ + const groessen = await seite.evaluate(() => ({ + fuss: parseFloat(getComputedStyle(document.querySelector(".vw-palette-fuss")).fontSize), + zahl: parseFloat(getComputedStyle(document.querySelector(".vw-palette-zahl")).fontSize), + titel: parseFloat(getComputedStyle(document.querySelector(".vw-pal-titel")).fontSize), + })); + pruefe(wo + ": der Tastaturhinweis ist kleiner als ein Treffer", + groessen.fuss < groessen.titel, + "Hinweis " + groessen.fuss + "px, Treffer " + groessen.titel + "px"); + pruefe(wo + ": der Zaehler ebenfalls", + groessen.zahl < groessen.titel, + "Zaehler " + groessen.zahl + "px, Treffer " + groessen.titel + "px"); + + /* ---- 5. Nicht bei jedem Tastendruck suchen ---- */ + pruefe(wo + ": es ging genau EINE Abfrage hinaus, nicht sechs", + abfragen.length === 1, abfragen.length + ": " + abfragen.join(",")); + + /* ---- 6. Tastaturbedienung ---- */ + await seite.keyboard.press("ArrowDown"); + await seite.waitForTimeout(150); + let nach = await seite.evaluate(() => ({ + aktiv: document.getElementById("vw-pal-feld").getAttribute("aria-activedescendant"), + fokus: document.activeElement.id, + })); + pruefe(wo + ": Pfeil runter waehlt den zweiten Treffer", nach.aktiv === "vw-pal-o1", nach.aktiv); + /* Der wichtigste Punkt des ARIA-Musters: Der Fokus bleibt im Feld, + damit man weitertippen kann. */ + pruefe(wo + ": der Fokus bleibt dabei im Eingabefeld", nach.fokus === "vw-pal-feld", nach.fokus); + + await seite.keyboard.press("ArrowUp"); + await seite.keyboard.press("ArrowUp"); + await seite.waitForTimeout(150); + nach = await seite.evaluate(() => document.getElementById("vw-pal-feld").getAttribute("aria-activedescendant")); + pruefe(wo + ": Pfeil hoch laeuft am Anfang zum Ende um", nach === "vw-pal-o3", nach); + + /* ---- 7. Enter springt in den richtigen Reiter ---- */ + await seite.fill("#vw-pal-feld", "Muster"); + await seite.waitForTimeout(500); + await seite.keyboard.press("ArrowDown"); + await seite.keyboard.press("ArrowDown"); // Projekt + await seite.keyboard.press("Enter"); + await seite.waitForTimeout(600); + pruefe(wo + ": die Palette schliesst sich nach der Auswahl", + !(await seite.locator("#vw-palette").isVisible())); + pruefe(wo + ": und der passende Reiter ist offen", + (await seite.locator("#vw-titel").innerText()).trim() === "Projekte", + await seite.locator("#vw-titel").innerText()); + + /* ---- 8. Escape und Fokusrueckgabe ---- */ + await seite.click("#vw-suche-auf"); + await seite.waitForTimeout(250); + await seite.keyboard.press("Escape"); + await seite.waitForTimeout(250); + pruefe(wo + ": Escape schliesst", !(await seite.locator("#vw-palette").isVisible())); + pruefe(wo + ": der Fokus kehrt zum Lupenknopf zurueck", + await seite.evaluate(() => document.activeElement.id === "vw-suche-auf"), + await seite.evaluate(() => document.activeElement.id)); + + /* ---- 9. Nichts gefunden ---- */ + await seite.click("#vw-suche-auf"); + await seite.fill("#vw-pal-feld", "nixdagibtes"); + await seite.waitForTimeout(500); + const leer = await seite.locator("#vw-pal-liste").innerText(); + pruefe(wo + ": 'nichts gefunden' steht ausdruecklich da", /Nichts gefunden/.test(leer), leer); + pruefe(wo + ": und aria-expanded geht wieder auf false", + (await seite.evaluate(() => document.getElementById("vw-pal-feld").getAttribute("aria-expanded"))) === "false"); + + /* ---- 10. Ein Serverfehler darf NICHT wie 'nichts gefunden' aussehen ---- */ + fehlerWerfen = true; + await seite.fill("#vw-pal-feld", "Muster"); + await seite.waitForTimeout(600); + const fehlerText = await seite.locator("#vw-pal-liste").innerText(); + pruefe(wo + ": ein Serverfehler wird als Fehler benannt", + /fehlgeschlagen/i.test(fehlerText), fehlerText); + pruefe(wo + ": und sieht anders aus als 'nichts gefunden'", + !/Nichts gefunden/.test(fehlerText), fehlerText); + fehlerWerfen = false; + + /* ---- 11. Das Wettrennen der Antworten ---- */ + /* Die erste Abfrage wird kuenstlich langsam gemacht. Kommt sie NACH + der zweiten an, darf sie nicht mehr zeichnen -- sonst sieht man + beim Weitertippen ploetzlich wieder die alten Treffer. */ + await seite.fill("#vw-pal-feld", ""); + await seite.waitForTimeout(300); + langsamMachen = true; + await seite.fill("#vw-pal-feld", "langsam"); + await seite.waitForTimeout(250); + langsamMachen = false; + await seite.fill("#vw-pal-feld", "nixdagibtes"); + await seite.waitForTimeout(1200); + const zuletzt = await seite.locator("#vw-pal-liste").innerText(); + pruefe(wo + ": die spaet eintreffende alte Antwort ueberschreibt die neue NICHT", + /Nichts gefunden/.test(zuletzt), zuletzt.slice(0, 80)); + + await seite.keyboard.press("Escape"); + pruefe(wo + ": kein Skriptfehler", protokoll.length === 0, protokoll.join(" | ")); + + /* ---- 12. Bild ---- */ + await seite.click("#vw-suche-auf"); + await seite.fill("#vw-pal-feld", "Muster"); + await seite.waitForTimeout(500); + await seite.screenshot({ path: "bild-suche-" + wo.toLowerCase() + ".png" }); + await browser.close(); +} + +/* Strg+K nur auf dem Computer -- ein Handy hat keine Strg-Taste. */ +{ + const browser = await chromium.launch(); + const seite = await browser.newPage({ viewport: { width: 1440, height: 950 } }); + await seite.route("**/webdesign/admin/**", (route) => route.fulfill({ json: { ok: true, + beiMir: { anfragen: { n: 0 }, nachrichten: { n: 0 }, wuensche: { n: 0 }, widerrufe: { n: 0 }, projekte: 0 }, + beiKunde: { projekte: 0, aufgaben: 0, zahlungen: { n: 0, summe: 0 }, zahlungenText: "0 €" }, + geld: { monat: { n: 0, bruttoText: "0 €", nettoText: "0 €" }, gesamt: { n: 0, bruttoText: "0 €" }, gebuehrenText: "0 €" }, + projekte: [], verlauf: [], bestand: {}, treffer: [], anfragen: [], kunden: [] } })); + await seite.route("**/auth/**", (r) => r.fulfill({ json: { ok: true } })); + await seite.addInitScript(() => { + sessionStorage.setItem("wd-vw-token", "t"); + sessionStorage.setItem("wd-sitzung-aktiv", "1"); + }); + await seite.goto("http://127.0.0.1:4353/webdesign/verwaltung.html"); + await seite.waitForTimeout(700); + console.log("\n=== Tastenkuerzel ==="); + await seite.keyboard.press("Control+k"); + await seite.waitForTimeout(300); + pruefe("Strg+K oeffnet die Suche von ueberall", + await seite.locator("#vw-palette").isVisible()); + await seite.keyboard.press("Control+k"); + await seite.waitForTimeout(300); + pruefe("und schliesst sie wieder", !(await seite.locator("#vw-palette").isVisible())); + 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/server-internal/index.js b/server-internal/index.js index 545aa3c7..1b0133a7 100644 --- a/server-internal/index.js +++ b/server-internal/index.js @@ -129,6 +129,7 @@ import { import { annahmeVorschau, anfrageAnnehmen, absageVorschau, anfrageAblehnen, } from "./routes/webdesign-annahme.js"; +import { suche } from "./routes/webdesign-suche.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 @@ -361,6 +362,7 @@ app.post("/webdesign/portal/zahlung/:id/abschluss", wrap(zahlungAbschliessen)); app.get("/webdesign/admin/zahlungen", wrap(zahlungenListe)); app.post("/webdesign/admin/zahlungen", wrap(zahlungAnlegen)); app.get("/webdesign/admin/uebersicht", wrap(uebersicht)); +app.get("/webdesign/admin/suche", wrap(suche)); app.get("/webdesign/admin/einstellungen", wrap(einstellungenLesen)); app.post("/webdesign/admin/einstellungen", wrap(einstellungenSchreiben)); app.post("/webdesign/admin/einstellungen/pruefen", wrap(einstellungenPruefen)); diff --git a/server-internal/routes/webdesign-suche.js b/server-internal/routes/webdesign-suche.js new file mode 100644 index 00000000..4dac50d8 --- /dev/null +++ b/server-internal/routes/webdesign-suche.js @@ -0,0 +1,156 @@ +/* ===================================================================== + webdesign-suche.js — eine Suche über alles + + WARUM ES DIE BRAUCHT + + Bisher gab es genau ein Suchfeld, und es durchsuchte nur die + Anfragenliste. Wer den Namen eines Kunden im Kopf hatte, musste + raten, in welchem Reiter er nachsehen muss: War das eine Anfrage, + ein laufendes Projekt, eine offene Rechnung? Bei drei Vorgängen + merkt man sich das. Bei dreissig nicht mehr. + + DREI ENTSCHEIDUNGEN + + 1. EIN AUFRUF FÜR ALLES. Vier einzelne Abfragen würden die Liste + ruckelnd aufbauen und bei jedem getippten Buchstaben viermal + losgeschickt. Hier kommt alles zusammen zurück, sortiert. + + 2. GESUCHT WIRD, WAS MAN IM KOPF HAT. Also Name, E-Mail, Firma, + Projekttitel, Vorgangsnummer -- nicht Beschreibungstexte. Wer + "Muster" tippt, meint den Kunden Muster, nicht jede Anfrage, in + deren Fliesstext das Wort vorkommt. Volltextsuche über alles + klingt mächtig und liefert vor allem Rauschen. + + 3. TREFFER TRAGEN IHREN ZUSAMMENHANG MIT. Ein Ergebnis "Anna Muster" + allein hilft nicht -- es steht dabei, ob das eine Anfrage von + vorgestern oder ein laufendes Projekt ist. Sonst muss man jeden + Treffer anklicken, um zu wissen, ob er gemeint war. + ===================================================================== */ +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 { text, centZuText } from "../lib/webdesign-helfer.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 }; + }); +} + +/* Sonderzeichen von LIKE unschädlich machen. + + Ein Prozentzeichen im Suchbegriff wäre sonst ein Platzhalter: Die + Suche nach "50%" fände alles, was mit "50" beginnt. Der Unterstrich + steht für ein beliebiges Zeichen -- eine Suche nach "a_b" fände + "axb". Beides ist kein theoretischer Fall: E-Mail-Adressen enthalten + regelmässig Unterstriche. + + Der Backslash als Fluchtzeichen muss ZUERST ersetzt werden, sonst + verdoppelt der zweite Durchlauf die eben eingefügten. */ +function fuerLike(begriff) { + return begriff + .replace(/\\/g, "\\\\") + .replace(/%/g, "\\%") + .replace(/_/g, "\\_"); +} + +const GRENZE = 6; // je Bereich -- mehr passt nicht auf einen Blick + +export async function suche(req, res) { + const p = await darf(req, "WD_ANFRAGEN_VIEW"); + if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); + + const roh = text(req.query?.q, 80) || ""; + const begriff = roh.trim(); + + /* Unter zwei Zeichen wird nicht gesucht. Ein einzelner Buchstabe + trifft fast alles und ist damit keine Auskunft, sondern eine + Liste -- und er belastet die Datenbank bei jedem Tastendruck. */ + if (begriff.length < 2) { + return json(res, { ok: true, begriff, zuKurz: true, treffer: [], anzahl: 0 }); + } + + const muster = "%" + fuerLike(begriff) + "%"; + const treffer = []; + + /* ---- Anfragen ---- */ + for (const a of db.prepare( + `SELECT id, nummer, name, email, paket, status, erstellt_am + FROM wd_anfragen + WHERE archiviert = 0 + AND (name LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' OR nummer LIKE ? ESCAPE '\\') + ORDER BY erstellt_am DESC LIMIT ?` + ).all(muster, muster, muster, GRENZE)) { + treffer.push({ + art: "anfrage", id: a.id, titel: a.name, + unten: a.nummer + " · " + (a.email || ""), + marke: a.status, ziel: "anfragen", + }); + } + + /* ---- Kunden ---- */ + for (const k of db.prepare( + `SELECT id, name, email, firma, gesperrt, freigeschaltet + FROM wd_kunden + WHERE archiviert = 0 + AND (name LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' OR firma LIKE ? ESCAPE '\\') + ORDER BY name LIMIT ?` + ).all(muster, muster, muster, GRENZE)) { + treffer.push({ + art: "kunde", id: k.id, titel: k.name, + unten: (k.firma ? k.firma + " · " : "") + (k.email || ""), + marke: k.gesperrt ? "gesperrt" : (k.freigeschaltet ? "aktiv" : "noch nicht frei"), + ziel: "kunden", + }); + } + + /* ---- Projekte ---- */ + for (const pr of db.prepare( + `SELECT p.id, p.nummer, p.titel, p.status, p.termin_am, k.name AS kunde_name + FROM wd_projekte p JOIN wd_kunden k ON k.id = p.kunde_id + WHERE p.archiviert = 0 + AND (p.titel LIKE ? ESCAPE '\\' OR p.nummer LIKE ? ESCAPE '\\' OR k.name LIKE ? ESCAPE '\\') + ORDER BY p.erstellt_am DESC LIMIT ?` + ).all(muster, muster, muster, GRENZE)) { + treffer.push({ + art: "projekt", id: pr.id, titel: pr.titel, + unten: pr.nummer + " · " + pr.kunde_name + + (pr.termin_am ? " · bis " + pr.termin_am : ""), + marke: pr.status, ziel: "projekte", + }); + } + + /* ---- Zahlungen ---- */ + for (const z of db.prepare( + `SELECT z.id, z.nummer, z.zweck_text, z.betrag_cent, z.waehrung, z.status, k.name AS kunde_name + FROM wd_zahlungen z LEFT JOIN wd_kunden k ON k.id = z.kunde_id + WHERE z.nummer LIKE ? ESCAPE '\\' OR z.zweck_text LIKE ? ESCAPE '\\' + OR k.name LIKE ? ESCAPE '\\' + ORDER BY z.erstellt_am DESC LIMIT ?` + ).all(muster, muster, muster, GRENZE)) { + treffer.push({ + art: "zahlung", id: z.id, titel: z.zweck_text || z.nummer, + unten: z.nummer + " · " + (z.kunde_name || "—") + " · " + centZuText(z.betrag_cent, z.waehrung), + marke: z.status, ziel: "zahlungen", + }); + } + + return json(res, { + ok: true, + begriff, + zuKurz: false, + treffer, + anzahl: treffer.length, + /* Ehrlich sagen, wenn abgeschnitten wurde. Eine Liste, die stillschweigend + bei sechs endet, liest sich wie "mehr gibt es nicht" -- und dann sucht + man den siebten Kunden vergeblich. */ + begrenzt: treffer.length >= GRENZE, + grenzeJeBereich: GRENZE, + }); +} diff --git a/server-internal/test-suche.mjs b/server-internal/test-suche.mjs new file mode 100644 index 00000000..d25d06ac --- /dev/null +++ b/server-internal/test-suche.mjs @@ -0,0 +1,160 @@ +/* ===================================================================== + test-suche.mjs — die Suche über alles + + Der heikelste Teil sind die Sonderzeichen von LIKE. Ein Prozentzeichen + im Suchbegriff wäre ohne Behandlung ein Platzhalter, ein Unterstrich + ebenfalls -- und Unterstriche stehen regelmässig in E-Mail-Adressen. + Der Fehler ist tückisch, weil die Suche trotzdem Treffer liefert, nur + die falschen. Man merkt es nie. + ===================================================================== */ +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-suche-")); +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"); + +const { initDb, db } = await import("./db.js"); +initDb(); +const geheim = await import("./lib/webdesign-geheimnisse.js"); +await geheim.geheimnisseLaden(); +const { suche } = await import("./routes/webdesign-suche.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(gueltigMs = 60_000) { + const n = Buffer.from(JSON.stringify({ + bereich: "wd-admin", rolle: "dogfather", exp: Date.now() + gueltigMs, + })).toString("base64url"); + return n + "." + createHmac("sha256", process.env.WEBDESIGN_API_SECRET).update(n).digest("base64url"); +} +async function such(q, kopf) { + let out = null; + await suche( + { headers: kopf || { authorization: "Bearer " + ausweis() }, cookies: {}, query: { q }, params: {} }, + { status() { return this; }, json(o) { out = o; return this; }, setHeader() {}, end() {} } + ); + return out; +} + +const jetzt = new Date().toISOString(); +db.prepare(`INSERT INTO wd_kunden (id,email,name,firma,erstellt_am) VALUES ('k1','anna@example.com','Anna Muster','Musterbau GmbH',?)`).run(jetzt); +db.prepare(`INSERT INTO wd_kunden (id,email,name,erstellt_am) VALUES ('k2','bert_meier@example.com','Bert Meier',?)`).run(jetzt); +db.prepare(`INSERT INTO wd_kunden (id,email,name,erstellt_am,gesperrt) VALUES ('k3','clara@example.com','Clara Sperr',?,1)`).run(jetzt); +db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,erstellt_am) + VALUES ('a1','A-2608-0001','Anna Muster','anna@example.com','website','neu',?)`).run(jetzt); +db.prepare(`INSERT INTO wd_anfragen (id,nummer,name,email,paket,status,erstellt_am) + VALUES ('a2','A-2608-0002','Dora 50% Rabatt','dora@example.com','shop','neu',?)`).run(jetzt); +db.prepare(`INSERT INTO wd_projekte (id,nummer,kunde_id,titel,paket,status,erstellt_am,termin_am) + VALUES ('p1','P-2608-0001','k1','Website für Musterbau','website','design',?, '2026-10-15')`).run(jetzt); +db.prepare(`INSERT INTO wd_zahlungen (id,nummer,kunde_id,art,zweck_text,betrag_cent,status,erstellt_am) + VALUES ('z1','Z-2608-0001','k1','anzahlung','Anzahlung Website',30000,'offen',?)`).run(jetzt); + +L("\n1. UEBER ALLE BEREICHE HINWEG"); +let r = await such("Muster"); +pruefe("die Suche antwortet", r && r.ok === true, JSON.stringify(r).slice(0, 100)); +const arten = new Set(r.treffer.map((t) => t.art)); +pruefe("sie findet die Anfrage", arten.has("anfrage")); +pruefe("sie findet den Kunden", arten.has("kunde")); +pruefe("sie findet das Projekt", arten.has("projekt")); +pruefe("sie findet die Zahlung ueber den KUNDENNAMEN", arten.has("zahlung")); +/* Vier Bereiche in einem Aufruf. Die erste Fassung erwartete drei und + uebersah, dass die Zahlung ueber den Namen des Kunden mitgefunden wird + -- was genau richtig ist: Wer "Muster" sucht, will auch dessen offene + Rechnung sehen, ohne sie ueber die Zahlungsnummer zu kennen. */ +pruefe("alle vier Bereiche in EINEM Aufruf", arten.size === 4, Array.from(arten).join(",")); + +L("\n2. JEDER TREFFER TRAEGT SEINEN ZUSAMMENHANG"); +const projektTreffer = r.treffer.find((t) => t.art === "projekt"); +pruefe("das Projekt nennt Nummer und Kunde", + /P-2608-0001/.test(projektTreffer.unten) && /Anna Muster/.test(projektTreffer.unten), + projektTreffer.unten); +pruefe("und den Liefertermin", /2026-10-15/.test(projektTreffer.unten), projektTreffer.unten); +pruefe("jeder Treffer weiss, wohin er fuehrt", + r.treffer.every((t) => !!t.ziel), JSON.stringify(r.treffer.map((t) => t.ziel))); +pruefe("jeder Treffer hat eine Marke fuer seinen Stand", + r.treffer.every((t) => !!t.marke), JSON.stringify(r.treffer.map((t) => t.marke))); + +L("\n3. SUCHE UEBER DEN KUNDENNAMEN FINDET SEIN PROJEKT"); +/* Man merkt sich den Menschen, nicht die Projektnummer. */ +r = await such("Musterbau"); +pruefe("die Firma des Kunden fuehrt zum Kunden", + r.treffer.some((t) => t.art === "kunde" && t.titel === "Anna Muster")); + +L("\n4. ZAHLUNGEN"); +r = await such("Anzahlung"); +pruefe("die Zahlung wird gefunden", r.treffer.some((t) => t.art === "zahlung")); +const z = r.treffer.find((t) => t.art === "zahlung"); +pruefe("mit lesbarem Betrag", /300,00/.test(z.unten), z.unten); + +L("\n5. LIKE-SONDERZEICHEN — hier entstehen falsche Treffer"); +/* Ohne Behandlung waere "%" ein Platzhalter und faende ALLES. */ +r = await such("%"); +pruefe("ein einzelnes Prozentzeichen ist zu kurz und sucht gar nicht", + r.zuKurz === true, JSON.stringify(r).slice(0, 80)); +r = await such("50%"); +pruefe("nach '50%' wird woertlich gesucht", + r.treffer.length === 1 && /Dora/.test(r.treffer[0].titel), + r.treffer.length + " Treffer: " + r.treffer.map((t) => t.titel).join(", ")); +/* Ohne Behandlung waere "_" ein beliebiges Zeichen: "bert_meier" faende + dann auch "bertXmeier". Geprueft wird umgekehrt -- ein Muster mit + Unterstrich darf NICHT auf etwas anderes passen. */ +r = await such("bert_meier"); +pruefe("ein Unterstrich wird woertlich genommen", + r.treffer.length === 1 && r.treffer[0].art === "kunde", + r.treffer.length + " Treffer"); +r = await such("bertxmeier"); +pruefe("und passt nicht auf ein beliebiges Zeichen", + r.treffer.length === 0, r.treffer.length + " Treffer"); + +L("\n6. ZU KURZ WIRD GAR NICHT GESUCHT"); +r = await such("a"); +pruefe("ein einzelner Buchstabe sucht nicht", r.zuKurz === true && r.treffer.length === 0); +r = await such(""); +pruefe("leer ebenfalls nicht", r.zuKurz === true); +r = await such(" "); +pruefe("nur Leerzeichen ebenfalls nicht", r.zuKurz === true); + +L("\n7. NICHTS GEFUNDEN IST EIN EIGENER ZUSTAND"); +r = await such("gibtesnichtxyz"); +pruefe("die Antwort ist gueltig", r.ok === true); +pruefe("mit null Treffern und NICHT zuKurz", + r.anzahl === 0 && r.zuKurz === false, JSON.stringify(r).slice(0, 90)); + +L("\n8. GESPERRTE KUNDEN WERDEN GEZEIGT, ABER GEKENNZEICHNET"); +/* Verstecken waere falsch: Man sucht sie ja gerade, WEIL etwas nicht + stimmt. Ohne Kennzeichnung wuerde man sich aber wundern. */ +r = await such("Sperr"); +pruefe("der gesperrte Kunde erscheint", r.treffer.some((t) => t.art === "kunde")); +pruefe("und ist als gesperrt gekennzeichnet", + r.treffer.find((t) => t.art === "kunde").marke === "gesperrt", + r.treffer.find((t) => t.art === "kunde").marke); + +L("\n9. ARCHIVIERTES TAUCHT NICHT AUF"); +db.prepare(`UPDATE wd_anfragen SET archiviert = 1 WHERE id = 'a1'`).run(); +r = await such("A-2608-0001"); +pruefe("eine archivierte Anfrage wird nicht mehr gefunden", + !r.treffer.some((t) => t.art === "anfrage"), JSON.stringify(r.treffer)); + +L("\n10. DIE RECHTEPRUEFUNG GREIFT"); +for (const [name, kopf] of [ + ["ohne Ausweis kein Ergebnis", {}], + ["mit gefaelschter Unterschrift kein Ergebnis", + { authorization: "Bearer " + ausweis().split(".")[0] + ".XXXX" }], + ["mit abgelaufenem Ausweis kein Ergebnis", + { authorization: "Bearer " + ausweis(-1000) }], +]) { + const a = await such("Muster", kopf); + pruefe(name, a && a.ok === false, JSON.stringify(a).slice(0, 70)); +} + +L(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`); +process.exitCode = fehler ? 1 : 0; +rmSync(tmp, { recursive: true, force: true }); diff --git a/webdesign/sw.js b/webdesign/sw.js index c1169859..b76c012a 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-v24"; +const CACHE_NAME = "dogfather-webdesign-v25"; /* 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 2172f2dc..e5eb315d 100644 --- a/webdesign/verwaltung.html +++ b/webdesign/verwaltung.html @@ -247,6 +247,149 @@ .vw-ub-kachel:hover:not(:disabled), .vw-ub-projekt:hover { transform: none; } } + /* ================================================================ + SUCHE ÜBER ALLES + + Liegt über der Seite, nicht daneben: Sie ist ein Werkzeug, das man + kurz benutzt und wieder wegtut. Ein fest eingebautes Suchfeld + nähme dauerhaft Platz für etwas, das man zehnmal am Tag für drei + Sekunden braucht. + ================================================================ */ + .vw-palette { position: fixed; inset: 0; z-index: 200; } + .vw-palette[hidden] { display: none; } + .vw-palette-hg { + position: absolute; inset: 0; + background: rgb(0 0 0 / .55); + backdrop-filter: blur(3px); + } + .vw-palette-kasten { + position: relative; + /* Oben statt mittig: Die Liste wächst nach unten, und ein mittig + zentrierter Kasten würde bei jedem Tastendruck springen. */ + margin: max(8vh, calc(1rem + var(--wd-sicher-oben, 0px))) auto 0; + width: min(680px, calc(100vw - 1.6rem)); + max-height: min(70vh, 620px); + display: flex; flex-direction: column; + background: var(--wd-flaeche-2, #16191d); + border: 1px solid rgb(var(--wd-blau-rgb) / .28); + border-radius: var(--wd-radius-l, 20px); + box-shadow: 0 24px 70px -20px rgb(0 0 0 / .85); + overflow: hidden; + } + .vw-palette-kopf { + display: flex; align-items: center; gap: .6rem; + padding: .85rem 1rem; + border-bottom: 1px solid rgb(255 255 255 / .07); + } + .vw-palette-lupe { color: var(--wd-text-soft); flex-shrink: 0; } + .vw-palette-feld { + flex: 1; min-width: 0; + background: none; border: 0; outline: none; + color: var(--wd-text); + /* 16px, damit iOS beim Antippen nicht in die Seite hineinzoomt. */ + font-size: 16px; font-family: var(--font-body); + } + .vw-palette-feld::placeholder { color: var(--wd-text-soft); } + .vw-palette-weg { + flex-shrink: 0; min-height: 44px; min-width: 44px; + background: rgb(255 255 255 / .06); border: 1px solid rgb(255 255 255 / .1); + border-radius: 8px; color: var(--wd-text-soft); + font-size: .74rem; font-weight: 700; cursor: pointer; + } + .vw-palette-weg:focus-visible { outline: 2px solid rgb(var(--wd-blau-rgb) / .95); outline-offset: 2px; } + + /* Zwei Klassen, weil `.wd p` (Klasse + Element) sonst gewinnt. + + Dieselbe Falle wie schon bei `.wd a`, `.wd .wd-btn--haupt` und den + Zwischenueberschriften der Uebersicht: Die Fusszeile und der Zaehler + erschienen in 17,9px statt 11,5px -- ein Tastaturhinweis, der so + gross ist wie der Inhalt, den er erklaert. */ + .wd .vw-palette-zahl { + margin: 0; padding: .35rem 1rem 0; + font-size: .76rem; color: var(--wd-text-soft); + } + .wd .vw-palette-zahl:empty { display: none; } + + .vw-palette-liste { + list-style: none; margin: 0; padding: .35rem; + overflow-y: auto; flex: 1; + } + .vw-pal-treffer { + display: flex; align-items: center; gap: .7rem; + padding: .6rem .65rem; border-radius: 10px; + cursor: pointer; + } + /* Die Auswahl ist eine FLÄCHE, kein Rahmen: Ein Rahmen verschiebt bei + manchen Schriftgrössen die Zeile um einen Pixel, und beim Durchgehen + mit den Pfeiltasten zappelt dann die ganze Liste. */ + .vw-pal-treffer.vw-pal-an { background: rgb(var(--wd-blau-rgb) / .16); } + .vw-pal-art { + flex-shrink: 0; width: 62px; text-align: center; + padding: .16rem 0; border-radius: 999px; + font-size: .68rem; font-weight: 800; letter-spacing: .03em; + text-transform: uppercase; + background: rgb(255 255 255 / .07); color: var(--wd-text-soft); + } + .vw-pal-art--anfrage { background: rgb(var(--wd-gold-rgb) / .16); color: var(--wd-gold); } + .vw-pal-art--kunde { background: rgb(var(--wd-blau-rgb) / .16); color: var(--wd-blau); } + .vw-pal-art--projekt { background: rgb(var(--wd-lila-rgb) / .18); color: var(--wd-lila); } + .vw-pal-art--zahlung { background: rgb(var(--wd-ok-rgb) / .16); color: var(--wd-ok); } + + .vw-pal-mitte { flex: 1; min-width: 0; display: flex; flex-direction: column; } + .vw-pal-titel { + font-size: .93rem; font-weight: 650; color: var(--wd-text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .vw-pal-unten { + font-size: .78rem; color: var(--wd-text-soft); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .vw-pal-marke { + flex-shrink: 0; font-size: .72rem; color: var(--wd-text-soft); + padding: .12rem .5rem; border-radius: 999px; + background: rgb(255 255 255 / .06); + } + + .wd .vw-pal-hinweis { + padding: 1.4rem 1rem; text-align: center; + color: var(--wd-text-soft); font-size: .9rem; + } + .vw-pal-hinweis--fehler { color: #e78278; } + + .wd .vw-palette-fuss { + margin: 0; padding: .55rem 1rem; + border-top: 1px solid rgb(255 255 255 / .07); + font-size: .72rem; color: var(--wd-text-soft); + } + .wd .vw-palette-fuss kbd { + display: inline-block; padding: .05rem .35rem; margin: 0 .1rem; + border: 1px solid rgb(255 255 255 / .14); border-radius: 5px; + background: rgb(255 255 255 / .05); font-size: .9em; + font-family: var(--font-body); + } + @media (max-width: 620px) { + /* Auf dem Handy gibt es keine Tastatur mit Strg -- der Hinweis wäre + dort eine Anleitung für etwas Unmögliches. */ + .wd .vw-palette-fuss { display: none; } + .vw-palette-kasten { max-height: min(80vh, 620px); } + } + + /* Der Lupenknopf in der Kopfzeile. */ + .vw-suche-auf { + display: inline-flex; align-items: center; justify-content: center; gap: .4rem; + min-height: 44px; padding: 0 .8rem; + background: rgb(255 255 255 / .05); + border: 1px solid var(--wd-rand); border-radius: 10px; + color: var(--wd-text-soft); cursor: pointer; font: inherit; font-size: .84rem; + } + .vw-suche-auf:hover { border-color: rgb(var(--wd-blau-rgb) / .4); color: var(--wd-text); } + .vw-suche-auf:focus-visible { outline: 2px solid rgb(var(--wd-blau-rgb) / .95); outline-offset: 2px; } + .vw-suche-auf kbd { + padding: .05rem .3rem; border: 1px solid rgb(255 255 255 / .14); + border-radius: 4px; font-size: .72rem; font-family: var(--font-body); + } + @media (max-width: 620px) { .vw-suche-auf kbd { display: none; } } + /* ---------- Kennzahlen, gehobene Fassung ---------- Die erste Fassung war eine Reihe stiller Zahlen. Zahlen, die man nur anschauen kann, sind Dekoration. Diese hier sind Werkzeuge: @@ -891,6 +1034,17 @@ class="vw-abzeichen" id="vw-pf-zaehler" hidden>0
+ + —
@@ -909,6 +1063,52 @@ 2. Was wartet auf den KUNDEN? (da bin ich raus) 3. Wie steht es ums Geld? (Umsatz, Gebühren, Offenes) Erst danach Projekte und Verlauf. --> + + +
Wird geladen…