Files
alpenwerk-hr/app/(app)/employees/page.tsx
Maximilian Stubhan f96773da0f Reports/Export builder (CSV/XLSX), plus a security fix pass
Adds the Berichte export pipeline (/api/export/{report,events,employees})
with shared CSV/XLSX writers in lib/export.ts and lib/reports-data.ts.

Security pass alongside it: sanitize .or() search terms against PostgREST
filter injection, sanitize spreadsheet cells against CSV/Excel formula
injection, stop leaking raw DB error messages to clients, harden the
service-role client with server-only, add baseline security headers, and
bump the vulnerable nested postcss via an override.
2026-07-15 20:34:27 +02:00

145 lines
5.9 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 { 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",
{ 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);
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>
);
}