Files
AfiS-Aktensystem/web/app/(app)/dokumente/[documentNumber]/page.tsx
T
HarlekinandClaude Opus 5 bbae8546a0 Zuletzt geöffnet als Sprungliste, ohne Verlauf
Die letzte offene Zeile aus TASKS.md. Sie blieb offen, weil eine Spur über
jeden Aktenaufruf schnell etwas anderes wird als eine Bequemlichkeit.

Vier Festlegungen halten sie auf der richtigen Seite:

  - Kein Verlauf. Der Primärschlüssel (userId, targetType, targetId) lässt je
    Nutzer und Datensatz genau eine Zeile zu, die überschrieben wird. Die
    Tabelle kann nicht sagen, wie oft oder in welcher Reihenfolge gearbeitet
    wurde. Zwanzig Einträge, älteres fällt hinten heraus.
  - Nur die eigene. Keine Ansicht, kein Adminbereich, keine Funktion mit
    fremder Kennung. Jede Abfrage nimmt p.userId.
  - Jederzeit löschbar, ohne Rückfrage und ohne Grabstein.
  - Vorabrufe zählen nicht. Ohne die Prüfung auf next-router-prefetch stünde
    dort, worüber jemand mit der Maus gefahren ist.

Gelesen wird über dieselben Sichtbarkeitsfragmente wie überall: eine
vorgemerkte Kennung ist keine Berechtigung. Steigt eine Akte über die
Freigabestufe, verschwindet sie ohne Lücke und ohne Platzhalter; Personalakten
brauchen zusätzlich personnel.read. Das Zugriffsprotokoll bleibt unberührt und
bleibt die einzige Auskunft darüber, wer wann was gelesen hat.

Sie steht auf der Übersicht und im leeren Feld der Kommandopalette. Der zweite
Ort ist der eigentliche.

Nebenbei: NEXT_DIST_DIR, damit ein Build neben einem laufenden
Entwicklungsserver nicht dasselbe .next-Verzeichnis abräumt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:52:23 +02:00

299 lines
9.6 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'
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 } 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)}
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>
)
}