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 { 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 (

{de.personnel.backlinksEmpty}

{canLink ? : null}
) } return (
{byRole.map(({ role, eintraege }) => (

{de.participant.roles[role] ?? role}

    {eintraege.map(({ case: akte }) => (
  • {akte.caseNumber} {akte.title} {de.case.statusNames[akte.status]}
  • ))}
))} {canLink ? : null}
) } 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 (
[l.level, l.name]))} /> {canEdit ? ( l.level <= permissions.clearanceLevel)} defaultLevel={Math.min(file.classification, permissions.clearanceLevel)} personnelFileId={file.id} /> ) : null}
) } 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 ( ) } /** * 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 ( ) } 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 (
{de.personnel.one}

{akte.displayName}

{de.personnel.headData}

l.level <= user.permissions.clearanceLevel)} statusTags={statusTags} departments={abteilungen} />

{de.assignment.heading}

}>

{de.document.heading}

}>

{de.personnel.backlinks}

} >

{de.chat.heading}

}>
) }