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 Vorgaengen merkt man sich das, bei dreissig nicht mehr. Jetzt: Strg+K von ueberall, auf dem Handy der Lupenknopf oben. Ein Aufruf durchsucht Anfragen, Kunden, Projekte und Zahlungen; jeder Treffer traegt seinen Zusammenhang (Nummer, Kunde, Betrag, Liefertermin) und fuehrt per Enter in den passenden Reiter, bei einer Anfrage direkt in die Detailansicht. Gebaut nach dem ARIA-Muster 'Combobox mit Listbox-Popup' aus den W3C Authoring Practices -- nachgeschlagen, nicht aus dem Gedaechtnis: role=combobox am Eingabefeld, aria-expanded, aria-controls, aria-activedescendant, role=listbox, role=option mit aria-selected. Der Fokus bleibt dabei im Eingabefeld, damit man weitertippen kann; die Auswahl wandert ueber aria-activedescendant. Ohne diese Auszeichnung waere ein Feld, das Vorschlaege einblendet, fuer einen Screenreader stumm -- das sieht man beim Testen mit den Augen nie. Vier Fallen ausdruecklich behandelt: - Nicht bei jedem Tastendruck suchen (180 ms Wartezeit): 'Musterbau' haette sonst neun Abfragen ausgeloest, acht davon veraltet. - Das Wettrennen der Antworten: Jede Abfrage bekommt eine laufende Nummer, nur die neueste darf zeichnen. Sonst ueberschreibt eine spaet eintreffende alte Antwort die neue. - Leer, laedt und Fehler sind eigene Zustaende. Ein Kasten, der bei einem Serverfehler leer bleibt, sieht aus wie 'nichts gefunden'. - LIKE-Sonderzeichen: '%' waere ein Platzhalter, '_' ein beliebiges Zeichen -- und Unterstriche stehen regelmaessig in E-Mail-Adressen. Der Fehler ist tueckisch, weil die Suche trotzdem Treffer liefert, nur die falschen. Ausserdem zum dritten Mal dieselbe Spezifitaetsfalle gefunden: '.wd p' schlug die eigene Regel, der Tastaturhinweis erschien in 17,9px statt 11,5px -- so gross wie der Inhalt, den er erklaert. Jetzt festgenagelt durch eine Pruefung, die Groessenverhaeltnisse vergleicht. Geprueft: 28 gegen eine echte Datenbank (darunter alle LIKE-Sonderzeichen), 64 im Browser auf Computer und Handy inkl. der ARIA-Vorgaben, des Wettrennens und des Fehlerzustands. Co-Authored-By: Claude Opus 5 <[email protected]>
157 lines
6.1 KiB
JavaScript
157 lines
6.1 KiB
JavaScript
/* =====================================================================
|
|
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,
|
|
});
|
|
}
|