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 { 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"; 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 } = 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 = () => { 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(); 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}%`; q = q.where((eb) => eb.or([eb("first_name", "ilike", like), eb("last_name", "ilike", like), eb("job_title", "ilike", like)]) ); } } // 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; }; const [rows, total] = await Promise.all([ base() .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() .select(({ fn }) => fn.countAll().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 }; }); const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE)); return (

{count ?? 0} Mitarbeiter:innen gefunden

{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
{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" />
); }