Files
alpenwerk-hr/app/(app)/employees/page.tsx
Maximilian Stubhan 901c5c426e Consolidation pass: HR-only access, effective-dated mutations, data integrity guards, test suite
Reworks the app from a two-role (hr_admin/manager) model to a single
HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/
reorg RPCs to actually defer future-dated changes via a new
pending_org_changes table instead of writing them immediately (applied
by a daily Vercel Cron route), makes reorg undo append-only instead of
deleting history, adds Karenz-return and history-date integrity guards,
deprecates the salary column, and adds explicit schema grants + perf
indexes needed to run against a fresh (non-hosted) Postgres instance.

Adds vitest unit + integration test suites (the latter against a real
local Supabase instance) covering all of the above, plus lint/typecheck/
build wiring (`npm run check`).
2026-07-14 20:32:20 +02:00

143 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Link from "next/link";
import { Suspense } from "react";
import { EmployeeFilters } from "@/components/employees/EmployeeFilters";
import { Avatar } from "@/components/ui/Avatar";
import { StatusChip } from "@/components/ui/StatusChip";
import { fmtDate } from "@/lib/format";
import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
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",
{ 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 {
query = query.or(`first_name.ilike.%${q}%,last_name.ilike.%${q}%,job_title.ilike.%${q}%`);
}
}
if (params.division) query = query.eq("division_id", params.division);
if (params.status) query = query.eq("status", params.status as EmploymentStatus);
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 rounded border border-border bg-white">
<table className="w-full min-w-[800px] text-sm">
<thead>
<tr className="border-b border-border bg-surface text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
<th className="px-4 py-3">Mitarbeiter:in</th>
<th className="px-4 py-3">Pers.-Nr.</th>
<th className="px-4 py-3">Bereich/Team</th>
<th className="px-4 py-3">Standort</th>
<th className="px-4 py-3">Eintritt</th>
<th className="px-4 py-3">Beschäftigung</th>
<th className="px-4 py-3">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 (
<tr key={e.id} className="border-b border-border last:border-0 hover:bg-surface">
<td className="px-4 py-3">
<Link href={`/employees/${e.id}`} className="flex items-center gap-3">
<Avatar firstName={e.first_name} lastName={e.last_name} />
<div>
<div className="font-semibold text-ink">
{e.first_name} {e.last_name}
</div>
<div className="text-xs text-ink-muted">{e.job_title}</div>
</div>
</Link>
</td>
<td className="px-4 py-3 text-ink-body">{e.personnel_number}</td>
<td className="px-4 py-3 text-ink-body">
<div>{division?.name ?? ""}</div>
<div className="text-xs text-ink-muted">{team?.name ?? ""}</div>
</td>
<td className="px-4 py-3 text-ink-body">{location?.name ?? ""}</td>
<td className="px-4 py-3 text-ink-body">{fmtDate(e.entry_date)}</td>
<td className="px-4 py-3 text-ink-body">
{e.employment_type} · {e.weekly_hours}h
</td>
<td className="px-4 py-3">
<StatusChip status={e.status} entryDate={e.entry_date} />
</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>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 text-sm">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<Link
key={p}
href={pageHref(params, p)}
className={`rounded px-3 py-1 ${p === page ? "bg-brand-500 text-white" : "text-ink-body hover:bg-surface"}`}
>
{p}
</Link>
))}
</div>
)}
</div>
);
}