Files
AfiS-Aktensystem/web/app/(app)/dokumente/[documentNumber]/page.tsx
T
HarlekinandClaude Opus 5 5f9f5d99fb 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>
2026-09-03 15:16:10 +02:00

301 lines
9.8 KiB
TypeScript

import type { Metadata } from 'next'
import { Suspense } from 'react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { db } from '@/lib/db'
import { requireUser } from '@/lib/auth/current'
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write'
import { loadDocumentSections, loadVisibleDocument } from '@/lib/documents/service'
import {
canEditDocument,
canFinalizeDocument,
canRetractDocument,
} from '@/lib/access/documentWrite'
import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { SkeletonLine } from '@/components/Skeleton'
import { Sections } from '@/components/sections/Sections'
import { Sheet } from '@/components/documents/Sheet'
import { SheetSwitch } from '@/components/documents/SheetSwitch'
import { saveDocumentSectionAction, saveDocumentSectionsAction } from '../actions'
import { DocumentActions } from './DocumentActions'
import { Attachments } from '@/components/files/Attachments'
import { listAttachments, resolvePhotoUrls } from '@/lib/files/service'
import { toAttachmentViews } from '@/lib/files/view'
import { trackOpen } from '@/lib/recent/track'
import type { EffectivePermissions } from '@/lib/auth/permissions'
export const dynamic = 'force-dynamic'
export async function generateMetadata({
params,
}: {
params: Promise<{ documentNumber: string }>
}): Promise<Metadata> {
const { documentNumber } = await params
return { title: documentNumber }
}
async function Body({
document,
permissions,
canEdit,
}: {
document: {
id: string
templateId: string
documentNumber: string
title: string
layout: string
createdAt: Date
caseId: string | null
}
permissions: EffectivePermissions
canEdit: boolean
}) {
const sections = await loadDocumentSections(permissions, document)
const photoUrls = await resolvePhotoUrls(
permissions,
sections.map((section) => ({
fieldKey: section.fieldKey,
visible: section.visible,
value: section.visible ? section.value : null,
type: section.field.type,
})),
)
const editor = (
<Sections
scope={`dokument:${document.documentNumber}`}
save={saveDocumentSectionAction.bind(null, document.documentNumber)}
saveAll={saveDocumentSectionsAction.bind(null, document.documentNumber)}
saveAllLabel={de.document.saveAll}
sections={sections}
canEdit={canEdit}
documentNumber={document.documentNumber}
topic={document.caseId ? `case:${document.caseId}` : `user:${permissions.userId}`}
photoUrls={photoUrls}
/>
)
if (document.layout !== 'DOSSIER') return editor
// Das Aktenblatt braucht die Kopfzeilen der Vorlage und die
// Foundation-Stufen für den Balken. Beide steuern keinen Zugriff.
const [template, foundationLevels] = await Promise.all([
db.documentTemplate.findUnique({
where: { id: document.templateId },
select: { sheetTitle: true, sheetSubtitle: true, name: true },
}),
db.foundationClearance.findMany({ orderBy: { sortOrder: 'asc' } }),
])
return (
<SheetSwitch
canEdit={canEdit}
editor={editor}
sheet={
<Sheet
sections={sections}
title={template?.sheetTitle ?? de.app.name}
subtitle={template?.sheetSubtitle ?? template?.name ?? document.title}
documentNumber={document.documentNumber}
issuedAt={document.createdAt}
foundationLevels={foundationLevels}
footerNote={de.document.footerNote}
photoUrls={photoUrls}
/>
}
/>
)
}
async function AttachmentBlock({
documentId,
documentNumber,
topic,
permissions,
canUpload,
}: {
documentId: string
documentNumber: string
topic: string
permissions: EffectivePermissions
canUpload: boolean
}) {
const anhaenge = await listAttachments(permissions, { documentId })
return (
<Attachments
target={{ documentNumber }}
topic={topic}
initial={toAttachmentViews(anhaenge)}
canUpload={canUpload}
canRelease={permissions.isAdmin}
/>
)
}
/**
* Ein Dokument.
*
* Dieselbe Darstellung trägt einen Vorfallsbericht und einen mehrseitigen
* Vertrag: was sie unterscheidet, steht in der Vorlage, nicht hier.
* Siehe docs/13-entscheidungen.md.
*/
export default async function DocumentPage({
params,
}: {
params: Promise<{ documentNumber: string }>
}) {
const user = await requireUser()
const { documentNumber } = await params
const doc = await loadVisibleDocument(user.permissions, documentNumber)
// Kein Zugriff heisst 404, nicht 403.
if (!doc) notFound()
if (doc.classification >= READ_AUDIT_FROM_LEVEL) {
recordRead({
actorId: user.id,
action: 'document.read',
targetType: 'Document',
targetId: doc.id,
clearanceAtTime: user.permissions.clearanceLevel,
meta: { documentNumber: doc.documentNumber, classification: doc.classification },
})
}
await trackOpen(user.id, 'DOCUMENT', doc.id)
const [levels, besitzer, verfasser] = await Promise.all([
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
doc.caseId
? db.case.findUnique({ where: { id: doc.caseId }, select: { caseNumber: true, title: true } })
: doc.personnelFileId
? db.personnelFile.findUnique({
where: { id: doc.personnelFileId },
select: { id: true, displayName: true },
})
: Promise.resolve(null),
db.user.findMany({
where: {
id: {
in: [doc.createdById, doc.finalizedById, doc.retractedById].filter(
(id): id is string => id !== null,
),
},
},
select: { id: true, displayName: true },
}),
])
const level = levels.find((l) => l.level === doc.classification)
const nameOf = new Map(verfasser.map((v) => [v.id, v.displayName]))
const canEdit = canEditDocument(user.permissions, doc).ok
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<header className="pl-3" style={classificationEdgeStyle(doc.classification)}>
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
<span className="font-mono text-xs text-text-dim">{doc.documentNumber}</span>
<StampMark level={doc.classification} name={level?.name ?? de.common.unknown} />
</div>
<h1 className="mt-2 font-condensed">{doc.title}</h1>
<dl className="mt-3 flex flex-wrap gap-x-8 gap-y-1 text-xs text-text-dim">
<div className="flex gap-2">
<dt className="font-condensed">{de.case.status}</dt>
<dd>{de.document.statusNames[doc.status] ?? doc.status}</dd>
</div>
<div className="flex gap-2">
<dt className="font-condensed">{de.document.kind}</dt>
<dd>{doc.templateKey}</dd>
</div>
<div className="flex gap-2">
<dt className="font-condensed">{de.case.createdBy}</dt>
<dd>{nameOf.get(doc.createdById) ?? doc.createdById}</dd>
</div>
</dl>
<p className="no-print mt-2 text-xs">
<Link href={`/dokumente/${doc.documentNumber}/export`}>{de.export.asPdf}</Link>
</p>
{besitzer ? (
<p className="mt-2 text-xs">
{'caseNumber' in besitzer ? (
<Link href={`/faelle/${besitzer.caseNumber}`}>
{de.document.ownerCase} · {besitzer.title}
</Link>
) : (
<Link href={`/personalakten/${besitzer.id}`}>
{de.document.ownerPersonnel} · {besitzer.displayName}
</Link>
)}
</p>
) : (
<p className="mt-2 text-xs text-text-dim">{de.document.ownerNone}</p>
)}
{doc.retractedAt ? (
<p className="mt-3 border-l-(length:--spacing-edge) border-stamp-4 pl-3 text-sm text-text-dim">
{de.document.retracted(
doc.retractedAt.toLocaleDateString('de-DE'),
nameOf.get(doc.retractedById ?? '') ?? doc.retractedById ?? de.common.unknown,
doc.retractionReason ?? '',
)}
</p>
) : doc.finalizedAt ? (
<p className="mt-3 font-condensed text-2xs text-text-dim">
{de.document.finalized(
doc.finalizedAt.toLocaleDateString('de-DE'),
nameOf.get(doc.finalizedById ?? '') ?? doc.finalizedById ?? de.common.unknown,
)}
</p>
) : null}
<DocumentActions
documentNumber={doc.documentNumber}
classification={doc.classification}
canFinalize={canFinalizeDocument(user.permissions, doc).ok}
canRetract={canRetractDocument(user.permissions, doc).ok}
canReclassify={canEdit}
levels={levels
.filter((l) => l.level <= user.permissions.clearanceLevel)
.map((l) => ({ level: l.level, name: l.name }))}
/>
</header>
<section className="mt-10">
<Suspense
fallback={
<div className="space-y-6">
<SkeletonLine width="10rem" />
<SkeletonLine />
<SkeletonLine width="12rem" />
<SkeletonLine />
</div>
}
>
<Body document={doc} permissions={user.permissions} canEdit={canEdit} />
</Suspense>
</section>
<section className="no-print mt-10">
<h2 className="font-condensed">{de.attachment.heading}</h2>
<div className="mt-4">
<Suspense fallback={<SkeletonLine width="50%" />}>
<AttachmentBlock
documentId={doc.id}
documentNumber={doc.documentNumber}
topic={doc.caseId ? `case:${doc.caseId}` : `user:${user.id}`}
permissions={user.permissions}
canUpload={canEdit}
/>
</Suspense>
</div>
</section>
</main>
)
}