Files
AfiS-Aktensystem/web/app/(app)/personalakten/[id]/page.tsx
T
Harlekin da4a067850 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.
2026-09-03 10:52:23 +02:00

255 lines
8.1 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 { requirePermission } from '@/lib/auth/current'
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit'
import {
listAssignments,
listStatusTags,
loadBacklinks,
loadVisiblePersonnelFile,
} from '@/lib/personnel/service'
import { listDocuments, listTemplatesFor } from '@/lib/documents/service'
import { canWritePersonnel } from '@/lib/access/documentWrite'
import { trackOpen } from '@/lib/recent/track'
import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { SkeletonLine } from '@/components/Skeleton'
import { DocumentList } from '@/components/documents/DocumentList'
import { NewDocument } from '@/components/documents/NewDocument'
import { PersonnelHead } from './PersonnelHead'
import { Assignments } from './Assignments'
import { LinkCase } from './LinkCase'
import type { EffectivePermissions } from '@/lib/auth/permissions'
export const dynamic = 'force-dynamic'
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const user = await requirePermission('personnel.read')
const { id } = await params
const akte = await loadVisiblePersonnelFile(user.permissions, id)
return { title: akte?.displayName ?? de.personnel.one }
}
/**
* Rückverweise: die Fälle, in denen diese Person vorkommt.
*
* Streamt in einer eigenen Suspense-Grenze nach, weil die Abfrage über die
* Sichtbarkeitsprüfung der Fallakten läuft und damit die teuerste auf dieser
* Seite ist. Die Kopfdaten stehen währenddessen schon.
*/
async function Backlinks({
file,
permissions,
canLink,
}: {
file: { id: string; userId: string | null }
permissions: EffectivePermissions
canLink: boolean
}) {
const { total, byRole } = await loadBacklinks(permissions, file)
if (total === 0) {
return (
<div>
<p className="text-text-dim">{de.personnel.backlinksEmpty}</p>
{canLink ? <LinkCase personnelFileId={file.id} /> : null}
</div>
)
}
return (
<div className="space-y-6">
{byRole.map(({ role, eintraege }) => (
<div key={role}>
<h3 className="font-condensed text-xs text-text-dim">
{de.participant.roles[role] ?? role}
</h3>
<ul className="mt-2 border-t border-line">
{eintraege.map(({ case: akte }) => (
<li key={`${role}:${akte.id}`} className="border-b border-line">
<Link
href={`/faelle/${akte.caseNumber}`}
prefetch
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-2 pl-3 text-text no-underline"
style={classificationEdgeStyle(akte.classification)}
>
<span className="font-mono text-xs text-text-dim">{akte.caseNumber}</span>
<span className="grow">{akte.title}</span>
<span className="font-condensed text-2xs text-text-dim">
{de.case.statusNames[akte.status]}
</span>
</Link>
</li>
))}
</ul>
</div>
))}
{canLink ? <LinkCase personnelFileId={file.id} /> : null}
</div>
)
}
async function Documents({
file,
permissions,
canEdit,
}: {
file: { id: string; classification: number }
permissions: EffectivePermissions
canEdit: boolean
}) {
const [documents, templates, levels] = await Promise.all([
listDocuments(permissions, { personnelFileId: file.id }),
canEdit ? listTemplatesFor('PERSON_DOCUMENT') : Promise.resolve([]),
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
])
return (
<div className="space-y-4">
<DocumentList
documents={documents}
levelName={new Map(levels.map((l) => [l.level, l.name]))}
/>
{canEdit ? (
<NewDocument
templates={templates}
levels={levels.filter((l) => l.level <= permissions.clearanceLevel)}
defaultLevel={Math.min(file.classification, permissions.clearanceLevel)}
personnelFileId={file.id}
/>
) : null}
</div>
)
}
async function AssignmentBlock({
fileId,
permissions,
canEdit,
}: {
fileId: string
permissions: EffectivePermissions
canEdit: boolean
}) {
const [assignments, members] = await Promise.all([
listAssignments(permissions, fileId),
canEdit
? db.user.findMany({
where: { isActive: true },
orderBy: { displayName: 'asc' },
take: 200,
select: { id: true, displayName: true },
})
: Promise.resolve([]),
])
return (
<Assignments
personnelFileId={fileId}
assignments={assignments}
members={members}
canEdit={canEdit}
/>
)
}
export default async function PersonnelPage({ params }: { params: Promise<{ id: string }> }) {
const user = await requirePermission('personnel.read')
const { id } = await params
const akte = await loadVisiblePersonnelFile(user.permissions, id)
// Kein Zugriff heisst 404, nicht 403: die Existenz ist selbst eine Information.
if (!akte) notFound()
// Harte Regel 3: jeder Lesezugriff ab Stufe 3 wird protokolliert.
if (akte.classification >= READ_AUDIT_FROM_LEVEL) {
recordRead({
actorId: user.id,
action: 'personnel.read',
targetType: 'PersonnelFile',
targetId: akte.id,
clearanceAtTime: user.permissions.clearanceLevel,
meta: { displayName: akte.displayName, classification: akte.classification },
})
}
await trackOpen(user.id, 'PERSONNEL', akte.id)
const [levels, statusTags] = await Promise.all([
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
listStatusTags(),
])
const level = levels.find((l) => l.level === akte.classification)
const canEdit = canWritePersonnel(user.permissions, akte.classification).ok
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<header className="pl-3" style={classificationEdgeStyle(akte.classification)}>
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
<span className="font-condensed text-xs text-text-dim">{de.personnel.one}</span>
<StampMark level={akte.classification} name={level?.name ?? de.common.unknown} />
</div>
<h1 className="mt-2 font-condensed">{akte.displayName}</h1>
</header>
<section className="mt-8">
<h2 className="font-condensed">{de.personnel.headData}</h2>
<div className="mt-4">
<PersonnelHead
file={akte}
canEdit={canEdit}
levels={levels.filter((l) => l.level <= user.permissions.clearanceLevel)}
statusTags={statusTags}
/>
</div>
</section>
<section className="mt-10">
<h2 className="font-condensed">{de.assignment.heading}</h2>
<div className="mt-4">
<Suspense fallback={<SkeletonLine width="50%" />}>
<AssignmentBlock
fileId={akte.id}
permissions={user.permissions}
canEdit={canEdit}
/>
</Suspense>
</div>
</section>
<section className="mt-10">
<h2 className="font-condensed">{de.document.heading}</h2>
<div className="mt-4">
<Suspense fallback={<SkeletonLine width="60%" />}>
<Documents file={akte} permissions={user.permissions} canEdit={canEdit} />
</Suspense>
</div>
</section>
<section className="mt-10">
<h2 className="font-condensed">{de.personnel.backlinks}</h2>
<div className="mt-4">
<Suspense
fallback={
<div className="space-y-3">
<SkeletonLine width="8rem" />
<SkeletonLine width="70%" />
<SkeletonLine width="55%" />
</div>
}
>
<Backlinks file={akte} permissions={user.permissions} canLink={canEdit} />
</Suspense>
</div>
</section>
</main>
)
}