Files
AfiS-Aktensystem/web/app/(app)/personalakten/[id]/page.tsx
T
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

303 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 { requirePermission } from '@/lib/auth/current'
import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit/write'
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 { listDepartments } from '@/lib/departments/service'
import { loadChatView } from '@/lib/chat/load'
import { ChatLazy } from '@/components/chat/ChatLazy'
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="record-row py-2 pl-3 text-text no-underline"
style={classificationEdgeStyle(akte.classification)}
>
<span className="record-key">{akte.caseNumber}</span>
<span className="record-title">{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}
/>
)
}
/**
* Der Chat einer Personalakte.
*
* Denselben formlosen Teil hat die Fallakte auch. Er steht bewusst **in** der
* Akte und nicht in einem freien Raum daneben: über eine Person zu reden, ohne
* dass das Gespräch bei ihrer Akte steht, war genau das, was vorher auf
* Discord passiert ist. Siehe docs/13-entscheidungen.md.
*
* Übernehmen als Vermerk gibt es hier nicht - dafür braucht es einen Verlauf,
* und den führt nur die Fallakte.
*/
async function ChatBlock({
file,
user,
}: {
file: { id: string }
user: { id: string; permissions: EffectivePermissions }
}) {
const ansicht = await loadChatView(user.permissions, { kind: 'personnel', fileId: file.id })
return (
<ChatLazy
scope={{ kind: 'personnel', fileId: file.id }}
topic={`personnel:${file.id}`}
initial={ansicht.messages}
initialReactions={ansicht.reactions}
currentUserId={user.id}
lastReadAt={ansicht.lastReadAt}
canAdopt={false}
mentionNames={ansicht.mentionNames}
/>
)
}
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, abteilungen] = await Promise.all([
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
listStatusTags(),
listDepartments(user.permissions),
])
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}
departments={abteilungen}
/>
</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>
<section className="mt-10">
<h2 className="font-condensed">{de.chat.heading}</h2>
<div className="mt-4">
<Suspense fallback={<SkeletonLine width="60%" />}>
<ChatBlock file={akte} user={user} />
</Suspense>
</div>
</section>
</main>
)
}