Search a name by any of its words, in any order

Reported from use: typing "Winkler micha" suggests there is no Winkler at
all, when there are fourteen. The search compared the whole term against
each field separately, so a two-word entry matched nothing — neither the
first name nor the last name contains "Michael Winkler" as a string. Both
orders failed; the report noticed one of them.

The term is now split on whitespace and every word must match somewhere.
That is more than was asked — the request was to search surname first — but
reversing the expected order only mirrors the problem: you would still have
to remember which way round it goes. "Winkler kath" and "kath Winkler" both
find the two Katharina Winklers now, and "Winkler Produktmanager" finds the
two in that job.

Matching runs against the concatenated name rather than the separate
columns, because that is exactly what idx_employees_name_trgm indexes. The
old query could not use it.

A second defect in the same block: the personnel-number branch tested
/^d+$/ — a missing backslash, so it matched strings of the letter d and
never a number. Searching "3488" fell through to the name search and found
nothing. It now reaches Peter Bauer.

Verified against the live database, before and after, for both orders and
for a plain surname, which still returns all fourteen.

One thing the report's screenshot cannot show any more: there is no Michael
Winkler in the current data. The database was reseeded, and those names are
from the previous set — worth knowing before checking with that exact name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 19:32:45 +02:00
parent f28fd2da60
commit 53d5d41784

View File

@@ -6,7 +6,7 @@ import { CARD_CLASS } from "@/components/ui/Card";
import { Pagination } from "@/components/ui/Pagination";
import { StatusChip } from "@/components/ui/StatusChip";
import { currentUserId } from "@/lib/auth/session";
import { withUser } from "@/lib/db";
import { sql, withUser } from "@/lib/db";
import { derivedStatusFilter } from "@/lib/employee-status-filter";
import { fmtDate, todayIso } from "@/lib/format";
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
@@ -78,15 +78,43 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
if (params.q) {
const term = params.q.trim();
if (/^d+$/.test(term)) {
// `\d`, nicht `d`: der fehlende Backslash liess die Ziffernerkennung
// nie greifen — „1590" wurde als Name gesucht und fand nichts,
// während das Muster auf „ddd" ansprang.
if (/^\d+$/.test(term)) {
q = q.where("personnel_number", "=", Number(term));
} else {
// Als Parameter gebunden statt in die Abfrage geschrieben: die
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, sind
// hier bedeutungslos.
const like = `%${term}%`;
// Wortweise statt am Stück, und **jedes** Wort muss irgendwo
// treffen.
//
// 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".
//
// 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.
//
// Verglichen wird gegen den zusammengesetzten Namen, weil genau
// darauf der Trigramm-Index liegt (idx_employees_name_trgm).
// Getrennte Felder hätten ihn ungenutzt gelassen.
const woerter = term.split(/\s+/).filter(Boolean);
q = q.where((eb) =>
eb.or([eb("first_name", "ilike", like), eb("last_name", "ilike", like), eb("job_title", "ilike", like)])
eb.and(
woerter.map((wort) => {
// Als Parameter gebunden, nicht in die Abfrage geschrieben.
const like = `%${wort}%`;
return eb.or([
eb(sql<string>`first_name || ' ' || last_name`, "ilike", like),
eb("job_title", "ilike", like),
]);
})
)
);
}
}