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 { derivedStatusFilter } from "@/lib/employee-status-filter"; import { fmtDate, fmtName, todayIso } from "@/lib/format"; import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } 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; }; 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. // 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", "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) ).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 )}

{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. {/* tabular-nums keeps the numeric columns aligned down the page instead of jittering per row. */} ); })} {employees.length === 0 && ( )}
Mitarbeiter:in Pers.-Nr. Bereich/Team Standort Eintritt Beschäftigung Status
{fmtName(e.first_name, e.last_name)}
{placement?.jobTitle ?? e.job_title}
{e.personnel_number}
{division?.name ?? "–"}
{/* 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. */}
{unit && unit.id !== division?.id ? 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" />
); }