Protokoll im Adminbereich, ein Knopf für das ganze Blatt

Die restlichen zwei Änderungswünsche.

**Protokoll.** „Wer was wo macht" stand seit dem ersten Tag in AuditLog —
harte Regel 3 sorgt dafür. Was fehlte, war die Ansicht darauf. /admin/protokoll
ist genau das und keine zweite Aufzeichnung: filterbar nach Person, Aktion,
Art und Zeitraum, rückwärts blätternd.

Nur für die Leitung. Eine Zeile nennt Aktenzeichen und Namen, und schon die
Existenz einer Zeile verrät, dass es einen Vorgang gibt; ein gefiltertes
Protokoll für alle wäre ein Weg, die Sichtbarkeitsregeln von hinten
aufzurollen. Aus demselben Grund werden Kennungen ohne Sichtbarkeitsfilter zu
Aktenzeichen aufgelöst — ein Protokoll aus UUIDs liest niemand, und eines, das
niemand liest, ist keine Kontrolle.

**Speichern.** Jedes Feld trug seinen eigenen Knopf: ein Arbeitsvertrag mit
zwölf Feldern hiess zwölfmal speichern. Der Knopf steht jetzt am Blatt.

Geschrieben wird trotzdem je Feld und nicht als Transaktion, und das folgt aus
dem Datenmodell: Abschnitte sind einzeln versioniert, weil mehrere Leute
gleichzeitig an einer Akte arbeiten. Als Transaktion blockierte ein einziges
zwischenzeitlich geändertes Feld das ganze Blatt — elf gute Änderungen verloren,
weil jemand das zwölfte angefasst hat. So geht durch, was durchgeht, und die
Rückmeldung sagt, was offen blieb und wo. Lichtbilder bleiben ausgenommen: ein
Upload mit Prüfkette ist keine Feldänderung.

lib/audit.ts liegt jetzt als lib/audit/write.ts neben lib/audit/query.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-03 15:16:10 +02:00
co-authored by Claude Opus 5
parent ef377f0123
commit 5f9f5d99fb
40 changed files with 1005 additions and 42 deletions
+242
View File
@@ -0,0 +1,242 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { requirePermission } from '@/lib/auth/current'
import { auditActions, auditActors, queryAuditLog, type AuditRow } from '@/lib/audit/query'
import { de } from '@/lib/i18n/de'
export const metadata: Metadata = { title: de.audit.heading }
export const dynamic = 'force-dynamic'
type Params = Record<string, string | string[] | undefined>
/**
* Das Zugriffsprotokoll.
*
* Es beantwortet die eine Frage, die eine Abteilungsleitung an ein Aktensystem
* hat: **wer hat was wo getan.** Nach harter Regel 3 aus CLAUDE.md entsteht
* die Spur bei jeder schreibenden Aktion und bei jedem Lesezugriff ab Stufe 3,
* ohne Ausnahme und ohne Möglichkeit, sie zu unterdrücken.
*
* Nur für die Leitung. Das folgt aus dem, was drinsteht: eine Zeile nennt
* Aktenzeichen und Namen, und schon die Existenz einer Zeile verrät, dass es
* einen Vorgang gibt. Ein „gefiltertes Protokoll für alle" wäre ein Weg, die
* Sichtbarkeitsregeln von hinten aufzurollen.
*
* Der Zustand steht in der Adresse, wie bei der Suche: teilbar, und der
* Zurück-Knopf tut das Richtige.
*/
export default async function AuditLogPage({
searchParams,
}: {
searchParams: Promise<Params>
}) {
await requirePermission('admin')
const params = await searchParams
const wert = (name: string): string | undefined => {
const roh = params[name]
const text = (Array.isArray(roh) ? roh[0] : roh)?.trim()
return text ? text : undefined
}
const filter = {
actorId: wert('wer'),
action: wert('was'),
targetType: wert('art'),
targetId: wert('ziel'),
from: datum(wert('ab')),
to: datum(wert('bis'), true),
before: wert('vor') ? BigInt(wert('vor')!) : undefined,
}
const [{ rows, more }, akteure, aktionen] = await Promise.all([
queryAuditLog(filter),
auditActors(),
auditActions(),
])
const arten = [...new Set(rows.map((zeile) => zeile.targetType))].sort()
const gefiltert = Boolean(
filter.actorId || filter.action || filter.targetType || filter.targetId || filter.from || filter.to,
)
return (
<section>
<h2 className="font-condensed">{de.audit.heading}</h2>
<p className="mt-1 max-w-(--container-measure) text-2xs text-text-dim">{de.audit.intro}</p>
<form method="get" className="mt-6 flex flex-wrap items-end gap-3">
<label className="text-2xs text-text-dim">
{de.audit.filterActor}
<select name="wer" defaultValue={filter.actorId ?? ''} className="field mt-1 text-2xs">
<option value="">{de.audit.all}</option>
{akteure.map((akteur) => (
<option key={akteur.id} value={akteur.id}>
{akteur.name}
</option>
))}
</select>
</label>
<label className="text-2xs text-text-dim">
{de.audit.filterAction}
<select name="was" defaultValue={filter.action ?? ''} className="field mt-1 text-2xs">
<option value="">{de.audit.all}</option>
{aktionen.map((aktion) => (
<option key={aktion} value={aktion}>
{de.audit.actions[aktion] ?? aktion}
</option>
))}
</select>
</label>
<label className="text-2xs text-text-dim">
{de.audit.filterFrom}
<input
type="date"
name="ab"
defaultValue={wert('ab') ?? ''}
className="field mt-1 font-mono text-2xs"
/>
</label>
<label className="text-2xs text-text-dim">
{de.audit.filterTo}
<input
type="date"
name="bis"
defaultValue={wert('bis') ?? ''}
className="field mt-1 font-mono text-2xs"
/>
</label>
{filter.targetType ? (
<input type="hidden" name="art" value={filter.targetType} />
) : null}
{filter.targetId ? <input type="hidden" name="ziel" value={filter.targetId} /> : null}
<button type="submit" className="btn btn-sm btn-primary">
{de.audit.apply}
</button>
{gefiltert ? (
<Link href="/admin/protokoll" className="btn btn-sm">
{de.audit.reset}
</Link>
) : null}
</form>
{arten.length > 1 || filter.targetType ? (
<div className="mt-3 flex flex-wrap gap-2">
<Link
href={adresse(params, { art: undefined, vor: undefined })}
aria-current={filter.targetType ? undefined : 'page'}
className="btn btn-sm"
>
{de.audit.all}
</Link>
{arten.map((art) => (
<Link
key={art}
href={adresse(params, { art, vor: undefined })}
aria-current={filter.targetType === art ? 'page' : undefined}
className="btn btn-sm"
>
{de.audit.targets[art] ?? art}
</Link>
))}
</div>
) : null}
{rows.length === 0 ? (
<p className="mt-8 text-text-dim">{de.audit.empty}</p>
) : (
<div className="mt-6 overflow-x-auto">
<table className="w-full border-collapse text-2xs">
<thead>
<tr className="border-b border-line text-left">
<th className="py-2 pr-4">{de.audit.when}</th>
<th className="py-2 pr-4">{de.audit.who}</th>
<th className="py-2 pr-4">{de.audit.what}</th>
<th className="py-2 pr-4">{de.audit.where}</th>
<th className="py-2">{de.audit.clearance}</th>
</tr>
</thead>
<tbody>
{rows.map((zeile) => (
<Zeile key={zeile.id} zeile={zeile} params={params} />
))}
</tbody>
</table>
</div>
)}
{more && rows.length > 0 ? (
<div className="mt-6">
<Link
href={adresse(params, { vor: rows[rows.length - 1]!.id })}
className="btn btn-sm"
>
{de.audit.older}
</Link>
</div>
) : null}
</section>
)
}
function Zeile({ zeile, params }: { zeile: AuditRow; params: Params }) {
return (
<tr className="border-b border-line align-baseline">
<td className="py-2 pr-4 font-mono whitespace-nowrap text-text-dim">
{zeile.at.toLocaleString('de-DE')}
</td>
<td className="py-2 pr-4">
<Link
href={adresse(params, { wer: zeile.actorId, vor: undefined })}
title={de.audit.ofUser}
className="no-underline"
>
{zeile.actorName}
</Link>
</td>
<td className="py-2 pr-4">{de.audit.actions[zeile.action] ?? zeile.action}</td>
<td className="py-2 pr-4">
<span className="text-text-dim">
{de.audit.targets[zeile.targetType] ?? zeile.targetType}
</span>{' '}
{zeile.targetHref ? (
<Link href={zeile.targetHref} className="font-mono">
{zeile.targetLabel}
</Link>
) : (
<span className="font-mono text-text-dim">{zeile.targetLabel ?? de.audit.gone}</span>
)}
</td>
<td className="py-2 font-mono text-text-dim">{zeile.clearanceAtTime}</td>
</tr>
)
}
/** Datum aus dem Formular. „Bis" meint den ganzen Tag, nicht Mitternacht. */
function datum(text: string | undefined, endeDesTages = false): Date | undefined {
if (!text) return undefined
const wert = new Date(text)
if (Number.isNaN(wert.getTime())) return undefined
if (endeDesTages) wert.setHours(23, 59, 59, 999)
return wert
}
/** Die eigene Adresse mit geänderten Parametern. */
function adresse(params: Params, aenderungen: Record<string, string | undefined>): string {
const such = new URLSearchParams()
for (const [name, roh] of Object.entries(params)) {
const text = Array.isArray(roh) ? roh[0] : roh
if (text) such.set(name, text)
}
for (const [name, wert] of Object.entries(aenderungen)) {
if (wert === undefined) such.delete(name)
else such.set(name, wert)
}
const rest = such.toString()
return rest ? `/admin/protokoll?${rest}` : '/admin/protokoll'
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { db } from '@/lib/db'
import { requirePermissionForAction } from '@/lib/auth/current'
import { writeAudit } from '@/lib/audit'
import { writeAudit } from '@/lib/audit/write'
import { de } from '@/lib/i18n/de'
const schema = z.object({
+1 -1
View File
@@ -4,7 +4,7 @@ import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { db } from '@/lib/db'
import { requirePermissionForAction } from '@/lib/auth/current'
import { writeAudit } from '@/lib/audit'
import { writeAudit } from '@/lib/audit/write'
import { templateFieldsSchema } from '@/lib/templates/schema'
import { de } from '@/lib/i18n/de'