diff --git a/app/(app)/employees/page.tsx b/app/(app)/employees/page.tsx index 50f0e18..ab4d4dd 100644 --- a/app/(app)/employees/page.tsx +++ b/app/(app)/employees/page.tsx @@ -50,7 +50,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps .map((s) => s.trim()) .filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus)); - const { orgMaps, employees, count, placements } = await withUser(await currentUserId(), async (tx) => { + const { orgMaps, employees, count, placements, ueberPosition } = await withUser(await currentUserId(), async (tx) => { // Die Referenzdaten zuerst: der Bereichsfilter braucht den Teilbaum. // „Produktion" meint die Abteilungen und Teams darunter — in der Einheit // selbst sitzt nur die Bereichsleitung. @@ -62,7 +62,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps // vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der // API-Schicht einen bedingt zusammengesetzten Select-String nicht // auflösen konnte. - const base = () => { + const base = (nurNamen: boolean) => { let q = tx.selectFrom("employees"); if (unitFilter) { @@ -91,44 +91,35 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps if (istPersonalnummer(term)) { q = q.where("personnel_number", "=", Number(term)); } else { - // Wortweise statt am Stück, und **jedes** Wort muss irgendwo - // treffen. + // ── Wie hier gesucht wird ────────────────────────────────── // - // Vorher wurde der ganze Begriff gegen jedes Feld einzeln geprüft. - // „Michael Winkler" stand damit in keinem Feld — weder Vor- noch - // Nachname enthalten die Zeichenkette — und die Suche kam auf null - // Treffer, obwohl es vierzehn Winkler gibt. Das las sich wie „die - // Person gibt es nicht". + // **Wortweise, Reihenfolge egal.** Jedes Wort muss treffen, aber + // nicht in einer bestimmten Ordnung: „Winkler micha" und „micha + // Winkler" führen beide zu Michaela Winkler. Am Stück gesucht stand + // „Michael Winkler" in keinem einzelnen Feld und ergab null Treffer, + // obwohl es vierzehn Winkler gibt. // - // Die Zerlegung macht die Reihenfolge nebensächlich: „Winkler - // micha" und „micha Winkler" führen beide zu Michaela Winkler. Das - // ist mehr, als die Rückmeldung verlangte — sie bat um die - // umgekehrte Reihenfolge —, aber eine Suche, bei der man sich die - // richtige Reihenfolge merken muss, hat dasselbe Problem nur - // gespiegelt. + // **Am Wortanfang, nicht mittendrin.** Als Teilzeichenkette traf ein + // „H" auf T-h-omas und Kat-h-arina — bei „Winkler H" kamen alle + // sieben Winkler zurück. Trennzeichen zählen als Wortgrenze, damit + // „dreher" auch „CNC-Dreher:in" findet. // - // Verglichen wird gegen den zusammengesetzten Namen, weil genau - // darauf der Trigramm-Index liegt (idx_employees_name_trgm). - // Getrennte Felder hätten ihn ungenutzt gelassen. - // Jedes Wort trifft am **Wortanfang**, nicht irgendwo mittendrin. + // **Namen vor Positionen.** Die Position mitzudurchsuchen ist + // nützlich („dreher"), darf aber eine Namenssuche nicht verwässern: + // bei „Winkler M" tauchten sonst Karin Winkler (Montagemitarbeiterin) + // und Katharina Winkler (Maschinenbedienerin) auf, weil ihre + // Position mit M beginnt. Deshalb wird zuerst nur über die Namen + // gesucht; nur wenn das *nichts* findet, kommt die Position dazu. + // Eine feste Mindestlänge fürs Wort wäre die einfachere Regel, aber + // jede Grenze wäre geraten — diese hier ergibt sich aus den Daten. // - // Vorher wurde jedes Wort als Teilzeichenkette gesucht. Bei „Winkler - // H" traf das „H" auf T-h-omas, Kat-h-arina und CNC-Dre-h-er:in — - // die Suche gab alle sieben Winkler zurück, obwohl genau eine Hannah - // heisst. Je kürzer die Eingabe, desto unbrauchbarer wurde sie, und - // ein Anfangsbuchstabe ist die kürzeste sinnvolle Eingabe überhaupt. - // - // Gesucht wird über Vorname, Nachname und Position zusammen, wobei - // Trennzeichen als Wortgrenze gelten: „dreher" findet damit auch - // „CNC-Dreher:in". Ein Wort trifft, wenn der Heuhaufen damit beginnt - // oder ein Leerzeichen davorsteht. - // - // Der Trigramm-Index auf dem zusammengesetzten Namen greift hier - // nicht mehr — das ist der Preis. Bei knapp neunhundert Zeilen liest - // Postgres die Tabelle in wenigen Millisekunden; die Genauigkeit ist - // das wert, und bei Bedarf trägt ein Index auf demselben Ausdruck - // das später wieder. - const heuhaufen = sql`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`; + // Der Trigramm-Index auf dem zusammengesetzten Namen greift bei + // diesem Ausdruck nicht mehr. Bei knapp neunhundert Zeilen liest + // Postgres die Tabelle in wenigen Millisekunden; ein Index auf + // demselben Ausdruck holt das zurück, sobald das nicht mehr stimmt. + const heuhaufen = nurNamen + ? sql`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')` + : sql`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`; q = q.where((eb) => eb.and( // Als Parameter gebunden, nicht in die Abfrage geschrieben. @@ -150,8 +141,17 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps return q; }; + // Erst nachsehen, ob die Namen allein etwas hergeben. Nur wenn nicht, + // wird die Position mitgesucht — eine zusätzliche, sehr kleine Abfrage, + // und nur bei einer Textsuche. + const sucheNachNamen = Boolean(params.q) && !istPersonalnummer(params.q!.trim()); + const namensTreffer = sucheNachNamen + ? Number((await base(true).select(({ fn }) => fn.countAll().as("anzahl")).executeTakeFirst())?.anzahl ?? 0) + : 0; + const nurNamen = sucheNachNamen && namensTreffer > 0; + const [rows, total] = await Promise.all([ - base() + base(nurNamen) .select([ "id", "first_name", @@ -173,7 +173,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps .limit(PAGE_SIZE) .offset((page - 1) * PAGE_SIZE) .execute(), - base() + base(nurNamen) .select(({ fn }) => fn.countAll().as("anzahl")) .executeTakeFirst(), ]); @@ -182,7 +182,15 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps // Seite, nicht für den ganzen Bestand. const placements = await loadPlacements(tx, { asOf: today, employeeIds: rows.map((e) => e.id) }); - return { orgMaps, employees: rows, count: Number(total?.anzahl ?? 0), placements }; + return { + orgMaps, + employees: rows, + count: Number(total?.anzahl ?? 0), + placements, + // Für den Hinweis über der Liste: wurde nach Namen gesucht, und hat es + // gereicht? + ueberPosition: sucheNachNamen && !nurNamen, + }; }); const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE)); @@ -192,7 +200,14 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps -

{count ?? 0} Mitarbeiter:innen gefunden

+

+ {count ?? 0} Mitarbeiter:innen gefunden + {/* Wenn kein Name passte, wurde nach der Position gesucht. Ohne diesen + Hinweis wirkt das Ergebnis, als hätte die Suche etwas erfunden. */} + {ueberPosition && (count ?? 0) > 0 && ( + · kein Namenstreffer, gesucht nach Position + )} +