Files
alpenwerk-hr/app/(app)/audit/page.tsx
Maximilian Stubhan e27db5f030 Phase 6/7: Reports builder and Audit log - all 7 routes now complete
Reports (§4.8):
- lib/reports.ts: generic server-side aggregation engine over 9 measures
  (Headcount, FTE, Eintritte, Austritte, Ø Bruttogehalt, Teilzeitquote,
  Ø Alter, Ø Zugehoerigkeit, Frauenanteil) x 10 group-by dimensions, with
  an optional second-dimension split (disabled for average-type
  measures) and per-row drill-down data.
- app/(app)/reports/page.tsx: reads filters from the URL, fetches the
  matching employees_directory rows server-side, aggregates in Node
  (not shipped raw to the client), computes the total.
- ReportsPageClient: measure/group/split/filter controls, 6 preset
  chips, saved-reports list (actions/reports.ts), CSV export
  (client-side blob download), stacked bars with a color-keyed legend
  when split is active, and click-to-drill-down into the underlying
  people (capped at 12, "+N weitere", linking to /employees/[id]).

Audit log (§4.9):
- app/(app)/audit/page.tsx + AuditFilters: search (target/details/actor)
  + action-type filter, paginated table with colored action badges,
  row links to the affected employee when target_employee_id is set,
  and the required "unveraenderbar" footer note.

This completes every route from the spec's information architecture:
Dashboard, Mitarbeiter:innen (list+detail), Organigramm (3 views),
Positionen & Bereiche, Berichte, Audit-Log, plus the Hire wizard and all
6 action panels reachable from them.

Final verification: clean npm run build + tsc --noEmit, then a full
browser walkthrough of all 6 authenticated routes as hr_admin (zero
console errors, zero 5xx responses) and a role-check pass as the
manager test account confirming action buttons and the "+ Neueinstellung"
button are hidden, and salary is masked as "... (ausgeblendet)" on the
Vertrag & Gehalt tab. Swept the database for leftover test data from
the debugging sessions above - none found, seed data is clean.
2026-07-13 23:11:23 +02:00

120 lines
4.5 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 { AuditFilters } from "@/components/audit/AuditFilters";
import { actionBadgeStyle } from "@/lib/colors";
import { fmtDate } from "@/lib/format";
import { createClient } from "@/lib/supabase/server";
const PAGE_SIZE = 25;
type SearchParams = { q?: string; action?: string; page?: string };
function pageHref(params: SearchParams, page: number): string {
const sp = new URLSearchParams();
if (params.q) sp.set("q", params.q);
if (params.action) sp.set("action", params.action);
sp.set("page", String(page));
return `/audit?${sp.toString()}`;
}
function fmtDateTime(iso: string): string {
return new Intl.DateTimeFormat("de-AT", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }).format(
new Date(iso)
);
}
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
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;
let query = supabase
.from("audit_log")
.select("id, occurred_at, actor_name, action, target_label, target_employee_id, details", { count: "exact" })
.order("occurred_at", { ascending: false })
.range(from, to);
if (params.action) query = query.eq("action", params.action);
if (params.q) {
const q = params.q.trim();
query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${q}%`);
}
const { data: entries, count } = await query;
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
return (
<div className="flex flex-col gap-4">
<Suspense>
<AuditFilters />
</Suspense>
<p className="text-sm text-ink-muted">{count ?? 0} Einträge</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">Zeitpunkt</th>
<th className="px-4 py-3">Benutzer:in</th>
<th className="px-4 py-3">Aktion</th>
<th className="px-4 py-3">Objekt</th>
<th className="px-4 py-3">Details</th>
</tr>
</thead>
<tbody>
{(entries ?? []).map((entry) => {
return (
<tr key={entry.id} className="border-b border-border last:border-0 hover:bg-surface">
<td className="px-4 py-3 text-ink-body">{fmtDateTime(entry.occurred_at)}</td>
<td className="px-4 py-3 text-ink-body">{entry.actor_name}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(entry.action)}`}>{entry.action}</span>
</td>
<td className="px-4 py-3 text-ink">
{entry.target_employee_id ? (
<Link href={`/employees/${entry.target_employee_id}`} className="hover:text-brand-700 hover:underline">
{entry.target_label}
</Link>
) : (
entry.target_label
)}
</td>
<td className="px-4 py-3 text-ink-muted">{entry.details ?? ""}</td>
</tr>
);
})}
{(entries ?? []).length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
Keine Einträge 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>
)}
<p className="text-xs text-ink-muted">
Alle Änderungen an Personal-Stammdaten werden automatisch protokolliert und sind unveränderbar.
</p>
</div>
);
}