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 { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json"; import { istPersonalnummer, suchMuster } from "@/lib/employee-search"; import { SORTIERFELDER, naechsteRichtung, sortiere, parseFeld, parseRichtung, type Richtung, type Sortierfeld, } from "@/lib/employee-sort"; import { derivedStatusFilter } from "@/lib/employee-status-filter"; import { fmtDate, fmtName, todayIso } from "@/lib/format"; import { breadcrumbLabel, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org"; import { loadPlacements } from "@/lib/placement"; import type { EmploymentStatus } from "@/lib/types"; const PAGE_SIZE = 15; /** * Die Parameter, nachdem sie geglättet sind — je einer, oder keiner. * * Was Next tatsächlich liefert, steht in RohParams: derselbe Name zweimal in * der Adresse wird dort zu einem Array. Diese Seite hat das nicht erwartet * und ist daran gescheitert — eine Kachel der Übersicht verwies auf * `?status=Aktiv&status=Karenz`, und `params.status.split(",")` lief gegen * ein Array. Sichtbar war davon nur „Diese Ansicht konnte nicht geladen * werden" mit einer Fehlerkennung. * * Die Kachel ist berichtigt (sie schreibt jetzt `status=Aktiv,Karenz`), aber * eine Adresse kommt nicht nur aus der eigenen Anwendung: sie steht in * Lesezeichen, in Verknüpfungen, in E-Mails. Deshalb wird hier geglättet * statt sich darauf zu verlassen, dass niemand zweimal denselben Namen * schreibt. */ type SearchParams = { q?: string; division?: string; status?: string; location?: string; sort?: string; dir?: string; page?: string; }; type RohParams = Record; /** Der erste Wert eines Parameters — auch wenn er mehrfach in der Adresse steht. */ function einWert(wert: string | string[] | undefined): string | undefined { return Array.isArray(wert) ? wert[0] : wert; } function glaetten(roh: RohParams): SearchParams { return { q: einWert(roh.q), division: einWert(roh.division), status: einWert(roh.status), location: einWert(roh.location), sort: einWert(roh.sort), dir: einWert(roh.dir), page: einWert(roh.page), }; } type EmployeesPageProps = { searchParams: Promise; }; 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); // Ohne das kippte die Liste beim Blättern zurück auf den Standard, und // Seite 2 zeigte Namen, die auf Seite 1 schon standen. if (params.sort) sp.set("sort", params.sort); if (params.dir) sp.set("dir", params.dir); sp.set("page", String(page)); return `/employees?${sp.toString()}`; } /** * Die Adresse hinter einem Spaltenkopf. * * Ohne `page`: nach dem Umsortieren steht auf Seite 7 etwas völlig anderes * als vorher. Wer sortiert, will von vorne anfangen. */ function sortHref(params: SearchParams, feld: Sortierfeld, aktuell: Sortierfeld, richtung: Richtung): 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("sort", feld); sp.set("dir", naechsteRichtung(aktuell, richtung, feld)); return `/employees?${sp.toString()}`; } export default async function EmployeesPage({ searchParams }: EmployeesPageProps) { const params = glaetten(await searchParams); const page = Math.max(1, Number(params.page ?? "1") || 1); const sortFeld = parseFeld(params.sort); const sortRichtung = parseRichtung(params.dir); 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. // Der Ausdrucksbauer wird durchgereicht, damit dieselbe Filterkette // einmal für die Seite und einmal für die Zählung in *einer* Abfrage // stehen kann. const base = (nurNamen: boolean, eb: OrgEb = tx as never) => { let q = eb.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`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. 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().as("anzahl")).executeTakeFirst())?.anzahl ?? 0) : 0; const nurNamen = sucheNachNamen && namensTreffer > 0; // Seite und Gesamtzahl in *einer* Rundreise. Als Promise.all sah das nach // Gleichzeitigkeit aus und war keine: eine Transaktion hängt an einer // Verbindung, und darüber laufen Abfragen nacheinander (lib/db/json.ts). const { rows, total } = await tx .selectNoFrom((eb) => [ jsonArrayFrom( base(nurNamen, eb) .select([ "id", "first_name", "last_name", "personnel_number", "job_title", "location_id", "entry_date", "employment_type", "weekly_hours", // Nicht `status`: der Chip leitet ab, wie der Filter es tut — // siehe components/ui/StatusChip.tsx. Drei Spalten mehr auf // fünfzehn Zeilen, keine zusätzliche Rundreise. "exit_date", "karenz_start_date", "karenz_return_date", "absence_type", ]) .$call((q) => sortiere(q, sortFeld, sortRichtung, today)) .limit(PAGE_SIZE) .offset((page - 1) * PAGE_SIZE) ).as("rows"), jsonObjectFrom(base(nurNamen, eb).select(({ fn }) => fn.countAll().as("anzahl"))).as("total"), ]) .executeTakeFirstOrThrow(); // 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 (

{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 )}

{/* Zur zweiten Achse siehe den Kommentar auf der Protokollseite: ohne `overflow-y-hidden` stuft CSS sie auf `auto` hoch, und die Karte bekommt eine senkrechte Leiste, die nichts bewirkt. */}
{SORTIERFELDER.map((f) => { const aktiv = f.value === sortFeld; return ( // `aria-sort` sagt einem Screenreader, welche Spalte die // Reihenfolge bestimmt und in welche Richtung — der Pfeil // allein ist für ihn nicht da. ); })} {employees.map((e) => { const placement = placements.get(e.id); 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. {/* tabular-nums keeps the numeric columns aligned down the page instead of jittering per row. */} {/* Nur die eigene Einheit, egal auf welcher Ebene sie hängt. Darüber stand der Bereich; der Kunde wollte ihn weg, weil seine Bereiche CEO, CFO, COO heissen und über jedem Namen dasselbe wiederholten. Der ganze Weg von oben steht weiterhin im `title` — eine Einheit wie „Shopleitung" sagt allein nicht, welche gemeint ist. */} ); })} {employees.length === 0 && ( )}
{f.label} {/* Der Pfeil steht immer da, nur blass, solange die Spalte nicht sortiert: sonst springt die Kopfzeile beim Klicken um eine Pfeilbreite. */} {aktiv && sortRichtung === "desc" ? "▼" : "▲"}
{fmtName(e.first_name, e.last_name)}
{placement?.jobTitle ?? e.job_title}
{e.personnel_number} {unit?.name ?? "–"} {location?.name ?? "–"} {fmtDate(e.entry_date)} {e.employment_type} · {e.weekly_hours}h
Keine Mitarbeiter:innen gefunden.
pageHref(params, p)} label="Mitarbeiter:innen" />
); }