Zu Anmerkung 5 ("In Audit schaut die Scrollbar komisch aus").
`overflow-x-auto` allein genuegt nicht: nach CSS wird eine auf `visible`
stehende Ueberlaufachse auf `auto` hochgestuft, sobald die andere nicht
`visible` ist. Die Karte war damit ein Scrollbereich in beiden Richtungen
und konnte eine senkrechte Leiste zeigen, die nichts bewirkt — die Karte ist
so hoch wie ihr Inhalt, es gibt dort nichts zu scrollen.
Geklemmt wird durch `overflow-y-hidden` nichts, aus demselben Grund. Die
drei Stellen mit diesem Muster sind Protokoll, Mitarbeiterliste und
Importmappe; alle drei sind gleich behandelt.
Ob das *die* Ursache des gemeldeten Bildes ist, ist damit nicht bewiesen —
aus dem Bildschirmfoto allein laesst sich nicht ablesen, zu welchem Element
die zweite Leiste gehoert. Es ist der einzige Scrollbereich auf der Seite
und eine Aufraeumung, die fuer sich steht.
150 lines
6.1 KiB
TypeScript
150 lines
6.1 KiB
TypeScript
import Link from "next/link";
|
|
import { Suspense } from "react";
|
|
import { AuditDetail } from "@/components/audit/AuditDetail";
|
|
import { AuditFilters } from "@/components/audit/AuditFilters";
|
|
import { CARD_CLASS } from "@/components/ui/Card";
|
|
import { Pagination } from "@/components/ui/Pagination";
|
|
import { actionBadgeStyle } from "@/lib/colors";
|
|
import { currentUserId } from "@/lib/auth/session";
|
|
import { withUser } from "@/lib/db";
|
|
|
|
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()}`;
|
|
}
|
|
|
|
// Pinned to Vienna and built once: audit_log.occurred_at is a timestamptz, and
|
|
// an unpinned formatter renders it in the *server's* zone — UTC in the
|
|
// container — so every entry would read an hour or two early for the people
|
|
// the log is for.
|
|
const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
|
|
day: "2-digit",
|
|
month: "2-digit",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
timeZone: "Europe/Vienna",
|
|
});
|
|
|
|
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
|
const params = await searchParams;
|
|
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
|
|
|
const { entries, count } = await withUser(await currentUserId(), async (tx) => {
|
|
const base = () => {
|
|
let q = tx.selectFrom("audit_log");
|
|
if (params.action) q = q.where("action", "=", params.action);
|
|
if (params.q) {
|
|
// Als Parameter gebunden statt in die Abfrage geschrieben: die
|
|
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, haben
|
|
// hier keine Bedeutung mehr.
|
|
const like = `%${params.q.trim()}%`;
|
|
q = q.where((eb) =>
|
|
eb.or([eb("target_label", "ilike", like), eb("details", "ilike", like), eb("actor_name", "ilike", like)])
|
|
);
|
|
}
|
|
return q;
|
|
};
|
|
|
|
const [entries, total] = await Promise.all([
|
|
base()
|
|
.select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details", "changes"])
|
|
// Nach id als zweitem Kriterium: bei gleichem Zeitstempel wäre die
|
|
// Reihenfolge sonst unbestimmt und ein Eintrag könnte auf zwei Seiten
|
|
// erscheinen oder auf keiner.
|
|
.orderBy("occurred_at", "desc")
|
|
.orderBy("id", "desc")
|
|
.limit(PAGE_SIZE)
|
|
.offset((page - 1) * PAGE_SIZE)
|
|
.execute(),
|
|
base()
|
|
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
|
.executeTakeFirst(),
|
|
]);
|
|
return { entries, count: Number(total?.anzahl ?? 0) };
|
|
});
|
|
|
|
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<Suspense>
|
|
<AuditFilters />
|
|
</Suspense>
|
|
<p className="text-sm text-ink-muted">{count} Einträge</p>
|
|
|
|
{/* `overflow-y-hidden` gehört zwingend dazu und ist keine Vorsicht:
|
|
nach CSS wird eine auf `visible` stehende Achse auf `auto`
|
|
hochgestuft, sobald die andere nicht `visible` ist. `overflow-x-auto`
|
|
allein macht aus der Karte also einen Scrollbereich in **beiden**
|
|
Richtungen — mit einer senkrechten Leiste, die niemand bestellt hat.
|
|
Geklemmt wird dabei nichts: die Karte ist so hoch wie ihr Inhalt. */}
|
|
<div className={`overflow-x-auto overflow-y-hidden ${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">Zeitpunkt</th>
|
|
<th className="px-4 py-2.5">Benutzer:in</th>
|
|
<th className="px-4 py-2.5">Aktion</th>
|
|
<th className="px-4 py-2.5">Objekt</th>
|
|
<th className="px-4 py-2.5">Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{entries.map((entry) => {
|
|
return (
|
|
<tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
|
|
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-ink-body">
|
|
{dateTimeFormatter.format(new Date(entry.occurred_at))}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-ink-body">{entry.actor_name}</td>
|
|
<td className="px-4 py-2.5">
|
|
<span className={`whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(entry.action)}`}>
|
|
{entry.action}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-2.5 text-ink">
|
|
{entry.target_employee_id ? (
|
|
<Link
|
|
href={`/employees/${entry.target_employee_id}`}
|
|
className="rounded font-semibold hover:text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
|
>
|
|
{entry.target_label}
|
|
</Link>
|
|
) : (
|
|
entry.target_label
|
|
)}
|
|
</td>
|
|
<td className="px-2 py-1.5">
|
|
<AuditDetail eintrag={entry} />
|
|
</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>
|
|
|
|
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => pageHref(params, p)} label="Audit-Log" />
|
|
|
|
<p className="text-xs text-ink-muted">
|
|
Alle Änderungen an Personal-Stammdaten werden automatisch protokolliert und sind unveränderbar.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|