"Winkler M" returned four people, two of whom are not called M: Karin Winkler is a Montagemitarbeiterin and Katharina Winkler a Maschinenbedienerin. The job title was searched with the same weight as the name, so a single letter matched the start of a job word just as readily as the start of a first name. Searching job titles is worth keeping — "dreher" finding the CNC-Dreher is useful. So the search is now tiered: names alone first, and the job title joins in only when the names return nothing at all. A minimum word length would have been the simpler rule, but any threshold is a guess; this one is decided by the data in front of it. Checked against the live data: "winkler m" gives Martin and Magdalena, "winkler h" Hannah, "dreher" and "montage" still find their trades, and "winkler montage" finds Karin Winkler — no name matches both words, so the fallback does what was meant. When the fallback runs, the result line says so. Without that, a list of people whose names look nothing like the query reads as though the search invented them. Costs one small count query, and only when text was typed. Not verified with next build: a dev server from an earlier session is holding .next, and the user is testing in it. tsc, eslint and 297 tests are green, and the search itself was run against the database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
287 lines
13 KiB
TypeScript
287 lines
13 KiB
TypeScript
import Link from "next/link";
|
||
import { Suspense } from "react";
|
||
import { EmployeeFilters } from "@/components/employees/EmployeeFilters";
|
||
import { Avatar } from "@/components/ui/Avatar";
|
||
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 { sql, withUser } from "@/lib/db";
|
||
import { istPersonalnummer, suchMuster } from "@/lib/employee-search";
|
||
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
||
import { fmtDate, todayIso } from "@/lib/format";
|
||
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
|
||
import { loadPlacements } from "@/lib/placement";
|
||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||
|
||
const PAGE_SIZE = 15;
|
||
|
||
type SearchParams = {
|
||
q?: string;
|
||
division?: string;
|
||
status?: string;
|
||
location?: string;
|
||
page?: string;
|
||
};
|
||
|
||
type EmployeesPageProps = {
|
||
searchParams: Promise<SearchParams>;
|
||
};
|
||
|
||
function pageHref(params: SearchParams, page: number): string {
|
||
const sp = new URLSearchParams();
|
||
if (params.q) sp.set("q", params.q);
|
||
if (params.division) sp.set("division", params.division);
|
||
if (params.status) sp.set("status", params.status);
|
||
if (params.location) sp.set("location", params.location);
|
||
sp.set("page", String(page));
|
||
return `/employees?${sp.toString()}`;
|
||
}
|
||
|
||
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
||
const params = await searchParams;
|
||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||
const today = todayIso();
|
||
|
||
// Comma-separated, so a dashboard tile can link here with the same
|
||
// status set it counted rather than a narrower one.
|
||
const statuses = (params.status ?? "")
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
||
|
||
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.
|
||
const orgMaps = await loadOrgMaps(tx);
|
||
const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null;
|
||
|
||
// Eine Filterkette, zwei Abfragen: eine für die Seite, eine für die
|
||
// Gesamtzahl. Am direkten Zugang teilen sie sich denselben Aufbau —
|
||
// vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der
|
||
// API-Schicht einen bedingt zusammengesetzten Select-String nicht
|
||
// auflösen konnte.
|
||
const base = (nurNamen: boolean) => {
|
||
let q = tx.selectFrom("employees");
|
||
|
||
if (unitFilter) {
|
||
// Nach Organisationseinheit gefiltert wird über die *laufende*
|
||
// Besetzung. Als EXISTS, damit eine Person nicht mehrfach erscheint,
|
||
// wenn sie über die Zeit mehrere Zuordnungen hatte.
|
||
const units = subtreeOf(orgMaps, unitFilter);
|
||
q = q.where((eb) =>
|
||
eb.exists(
|
||
eb
|
||
.selectFrom("position_assignments as a")
|
||
.innerJoin("om_positions as p", "p.id", "a.position_id")
|
||
.select("a.id")
|
||
.whereRef("a.employee_id", "=", "employees.id")
|
||
.where("a.valid_to", "is", null)
|
||
.where("p.org_unit_id", "in", units)
|
||
)
|
||
);
|
||
}
|
||
|
||
if (params.q) {
|
||
const term = params.q.trim();
|
||
// `\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 (istPersonalnummer(term)) {
|
||
q = q.where("personnel_number", "=", Number(term));
|
||
} else {
|
||
// ── Wie hier gesucht wird ──────────────────────────────────
|
||
//
|
||
// **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.
|
||
//
|
||
// **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.
|
||
//
|
||
// **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.
|
||
//
|
||
// 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<string>`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')`
|
||
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`;
|
||
q = q.where((eb) =>
|
||
eb.and(
|
||
// Als Parameter gebunden, nicht in die Abfrage geschrieben.
|
||
suchMuster(term).map(([amAnfang, nachLeerzeichen]) =>
|
||
eb.or([eb(heuhaufen, "like", amAnfang), eb(heuhaufen, "like", nachLeerzeichen)])
|
||
)
|
||
)
|
||
);
|
||
}
|
||
}
|
||
|
||
// Derived from the dates, not read off employees.status — see
|
||
// lib/employee-status-filter.ts for why the two can disagree.
|
||
if (statuses.length > 0) {
|
||
q = q.where((eb) => derivedStatusFilter(eb, statuses, today) ?? eb.val(true));
|
||
}
|
||
|
||
if (params.location) q = q.where("location_id", "=", params.location);
|
||
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<string>().as("anzahl")).executeTakeFirst())?.anzahl ?? 0)
|
||
: 0;
|
||
const nurNamen = sucheNachNamen && namensTreffer > 0;
|
||
|
||
const [rows, total] = await Promise.all([
|
||
base(nurNamen)
|
||
.select([
|
||
"id",
|
||
"first_name",
|
||
"last_name",
|
||
"personnel_number",
|
||
"job_title",
|
||
"location_id",
|
||
"entry_date",
|
||
"employment_type",
|
||
"weekly_hours",
|
||
"status",
|
||
"absence_type",
|
||
])
|
||
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die
|
||
// Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei
|
||
// Seiten erscheinen oder auf keiner.
|
||
.orderBy("last_name")
|
||
.orderBy("id")
|
||
.limit(PAGE_SIZE)
|
||
.offset((page - 1) * PAGE_SIZE)
|
||
.execute(),
|
||
base(nurNamen)
|
||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||
.executeTakeFirst(),
|
||
]);
|
||
|
||
// Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser
|
||
// 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,
|
||
// 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));
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<Suspense>
|
||
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} />
|
||
</Suspense>
|
||
<p className="text-sm text-ink-muted">
|
||
{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 && (
|
||
<span className="text-ink-muted"> · kein Namenstreffer, gesucht nach Position</span>
|
||
)}
|
||
</p>
|
||
|
||
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||
<table className="w-full min-w-[800px] text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||
<th className="px-4 py-2.5">Mitarbeiter:in</th>
|
||
<th className="px-4 py-2.5">Pers.-Nr.</th>
|
||
<th className="px-4 py-2.5">Bereich/Team</th>
|
||
<th className="px-4 py-2.5">Standort</th>
|
||
<th className="px-4 py-2.5">Eintritt</th>
|
||
<th className="px-4 py-2.5">Beschäftigung</th>
|
||
<th className="px-4 py-2.5">Status</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{employees.map((e) => {
|
||
const placement = placements.get(e.id);
|
||
const division = divisionOf(orgMaps, placement?.orgUnitId);
|
||
const unit = unitOf(orgMaps, placement?.orgUnitId);
|
||
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
|
||
return (
|
||
// border-subtle between rows: the full-strength border made
|
||
// an 800-row table read as a grid rather than a list.
|
||
<tr key={e.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
|
||
<td className="px-4 py-2.5">
|
||
<Link
|
||
href={`/employees/${e.id}`}
|
||
className="flex items-center gap-3 rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||
>
|
||
<Avatar firstName={e.first_name} lastName={e.last_name} />
|
||
<div className="min-w-0">
|
||
<div className="truncate font-semibold text-ink">
|
||
{e.first_name} {e.last_name}
|
||
</div>
|
||
<div className="truncate text-xs text-ink-muted">{placement?.jobTitle ?? e.job_title}</div>
|
||
</div>
|
||
</Link>
|
||
</td>
|
||
{/* tabular-nums keeps the numeric columns aligned down the
|
||
page instead of jittering per row. */}
|
||
<td className="px-4 py-2.5 tabular-nums text-ink-body">{e.personnel_number}</td>
|
||
<td className="px-4 py-2.5 text-ink-body">
|
||
<div>{division?.name ?? "–"}</div>
|
||
{/* Die eigene Einheit, egal auf welcher Ebene sie hängt —
|
||
eine Bereichsleitung sitzt am Bereich, nicht an einem
|
||
Team, und stand vorher deshalb ohne Zuordnung da. */}
|
||
<div className="text-xs text-ink-muted" title={breadcrumbLabel(orgMaps, placement?.orgUnitId)}>
|
||
{unit && unit.id !== division?.id ? unit.name : "–"}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-2.5 text-ink-body">{location?.name ?? "–"}</td>
|
||
<td className="px-4 py-2.5 tabular-nums text-ink-body">{fmtDate(e.entry_date)}</td>
|
||
<td className="px-4 py-2.5 text-ink-body">
|
||
{e.employment_type} · <span className="tabular-nums">{e.weekly_hours}h</span>
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<StatusChip status={e.status} entryDate={e.entry_date} absenceType={e.absence_type} />
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{employees.length === 0 && (
|
||
<tr>
|
||
<td colSpan={7} className="px-4 py-8 text-center text-sm text-ink-muted">
|
||
Keine Mitarbeiter:innen gefunden.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => pageHref(params, p)} label="Mitarbeiter:innen" />
|
||
</div>
|
||
);
|
||
}
|