Karenz was doing duty as the name for every kind of extended absence, but the cases behave differently in payroll and reporting — Wochenhilfe, a Präsenzdienst, a long sick leave and a sabbatical are not the same thing. The concept is now called Langzeitabwesenheit and carries which kind it is. - employees.absence_type, constrained to the thirteen kinds. start_karenz stores it on both paths (written straight away, or parked in the pending_org_changes payload when the absence starts later); record_karenz_return and the karenz_return branch of apply_due_pending_changes clear it, so a returned employee does not keep looking like they are still away. It also reaches employee_history, the audit log and the employee export. - The status enum value stays 'Karenz'. Postgres can rename an enum value in place, but every stored function body that spells it would then reference a value that no longer exists — a dozen functions across fifteen migrations, rewritten for a label. The mapping lives in lib/absence.ts instead, which is the single place the UI reads the display name from. - Where a kind is recorded the chip shows it — "Bildungskarenz" says more than "Langzeitabwesenheit". Absences predating the field have none and fall back to the generic name rather than to a guess, and a value outside the list is dropped rather than echoed into the UI. - The export prints the display name, not the raw enum: a payroll hand-off reading "Karenz" for what the app calls Langzeitabwesenheit only causes questions. Audit filter options keep their stored values and change only their labels. - The seed spreads the twelve absences across the kinds; all of them being Karenz would leave any breakdown by kind invisible.
153 lines
6.9 KiB
TypeScript
153 lines
6.9 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 { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
|
||
import { fmtDate, todayIso } from "@/lib/format";
|
||
import { breadcrumbFor, loadOrgMaps } 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;
|
||
|
||
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 supabase = await createClient();
|
||
const orgMaps = await loadOrgMaps(supabase);
|
||
|
||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||
const from = (page - 1) * PAGE_SIZE;
|
||
const to = from + PAGE_SIZE - 1;
|
||
|
||
let query = supabase
|
||
.from("employees")
|
||
.select(
|
||
"id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status, absence_type",
|
||
{ count: "exact" }
|
||
)
|
||
.order("last_name", { ascending: true })
|
||
.range(from, to);
|
||
|
||
if (params.q) {
|
||
const q = params.q.trim();
|
||
if (/^\d+$/.test(q)) {
|
||
query = query.eq("personnel_number", Number(q));
|
||
} else {
|
||
const term = sanitizeIlikeTerm(q);
|
||
query = query.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
||
}
|
||
}
|
||
if (params.division) query = query.eq("division_id", params.division);
|
||
// Comma-separated, so a dashboard tile can link here with the same
|
||
// definition it counted — "aktiv" across this app means Aktiv *and*
|
||
// Karenz, and a single-value filter would land the user on a smaller
|
||
// number than the tile they clicked.
|
||
const statuses = (params.status ?? "")
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
||
// Derived from the dates, not read off employees.status — see
|
||
// lib/employee-status-filter.ts for why the two can disagree.
|
||
query = applyDerivedStatusFilter(query, statuses, todayIso());
|
||
if (params.location) query = query.eq("location_id", params.location);
|
||
|
||
const { data: employeesData, count } = await query;
|
||
const employees = employeesData ?? [];
|
||
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<Suspense>
|
||
<EmployeeFilters divisions={orgMaps.divisionList} locations={orgMaps.locationList} />
|
||
</Suspense>
|
||
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</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 { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id);
|
||
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">{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>
|
||
<div className="text-xs text-ink-muted">{team?.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>
|
||
);
|
||
}
|