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 { applyDerivedStatusFilter } from "@/lib/employee-status-filter"; import { fmtDate, todayIso } from "@/lib/format"; import { loadPlacements } from "@/lib/placement"; import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org"; import { sanitizeIlikeTerm } from "@/lib/supabase/query"; import { createClient } from "@/lib/supabase/server"; import type { EmploymentStatus } from "@/lib/supabase/types"; const PAGE_SIZE = 15; const COLUMNS = "id, first_name, last_name, personnel_number, job_title, location_id, entry_date, employment_type, weekly_hours, status, absence_type"; // Was applyFilters vom Query-Builder braucht — mehr nicht. type Narrowable = { eq: (column: string, value: string | number) => Narrowable; or: (filters: string) => Narrowable; gt: (column: string, value: string) => Narrowable; lte: (column: string, value: string) => Narrowable; gte: (column: string, value: string) => Narrowable; is: (column: string, value: null) => Narrowable; not: (column: string, operator: string, value: null) => Narrowable; }; 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 supabase = await createClient(); const page = Math.max(1, Number(params.page ?? "1") || 1); const from = (page - 1) * PAGE_SIZE; const to = from + PAGE_SIZE - 1; const today = todayIso(); // Die Referenzdaten kommen zuerst, weil der Bereichsfilter den Teilbaum // braucht: „Produktion" meint die Abteilungen und Teams darunter, nicht die // Einheit selbst — dort sitzt nur die Bereichsleitung. const orgMaps = await loadOrgMaps(supabase); // Nach Organisationseinheit gefiltert wird über die laufende Besetzung. // `!inner` macht aus der Einbettung einen echten Join, sodass die Bedingung // die Person aus dem Ergebnis nimmt statt bloss ihre eingebettete Liste zu // leeren. Die Einbettung ändert die Form der Zeile, deshalb steht sie im // Select und nicht in einem nachträglichen Filter. const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null; // 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)); // Strukturell typisiert und generisch über den Builder, damit die beiden // Select-Formen unten ihre Zeilenform behalten. Ein bedingt // zusammengesetzter Select-String wird zu einer Union zweier Literale, die // der Typparser von postgrest-js nicht mehr auflösen kann — daher zwei // getrennte Abfragen mit einer gemeinsamen Filterkette. function applyFilters(query: Q): Q { let q = query; if (params.q) { const term = params.q.trim(); if (/^\d+$/.test(term)) q = q.eq("personnel_number", Number(term)) as Q; else { const safe = sanitizeIlikeTerm(term); q = q.or(`first_name.ilike.%${safe}%,last_name.ilike.%${safe}%,job_title.ilike.%${safe}%`) as Q; } } // Derived from the dates, not read off employees.status — see // lib/employee-status-filter.ts for why the two can disagree. q = applyDerivedStatusFilter(q, statuses, today); if (params.location) q = q.eq("location_id", params.location) as Q; return q; } const { data: employeesData, count } = unitFilter ? await applyFilters( supabase .from("employees") .select(`${COLUMNS}, position_assignments!inner(valid_to, om_positions!inner(org_unit_id))`, { count: "exact" }) .order("last_name", { ascending: true }) .range(from, to) .is("position_assignments.valid_to", null) .in("position_assignments.om_positions.org_unit_id", subtreeOf(orgMaps, unitFilter)) ) : await applyFilters( supabase.from("employees").select(COLUMNS, { count: "exact" }).order("last_name", { ascending: true }).range(from, to) ); const employees = employeesData ?? []; const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE)); // Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser // Seite, nicht für den ganzen Bestand. const placements = await loadPlacements(supabase, { asOf: today, employeeIds: employees.map((e) => e.id) }); 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" />
); }