import type { Metadata } from 'next' import { Suspense } from 'react' import { notFound } from 'next/navigation' import { db } from '@/lib/db' import { requireUser } from '@/lib/auth/current' import { visibleEntryWhere } from '@/lib/access/caseFilter' import { loadVisibleCase } from '@/lib/cases/service' import { recordRead, READ_AUDIT_FROM_LEVEL } from '@/lib/audit' import { de } from '@/lib/i18n/de' import { StampMark, classificationEdgeStyle } from '@/components/StampMark' import { SkeletonLine } from '@/components/Skeleton' import { EntryComposer } from './EntryComposer' import { Sections } from '@/components/sections/Sections' import { saveSectionAction } from './sectionActions' import { loadSections } from '@/lib/cases/sectionService' import { canEditCase } from '@/lib/access/caseWrite' import { CaseActions } from './CaseActions' import { EntryActions } from './EntryActions' import { Participants } from './Participants' import { CaseLinks } from './CaseLinks' import { listParticipants } from '@/lib/cases/participants' import { listLinks } from '@/lib/cases/links' import { listDocuments, listTemplatesFor } from '@/lib/documents/service' import { visiblePersonnelWhere } from '@/lib/access/personnelFilter' import { DocumentList } from '@/components/documents/DocumentList' import { NewDocument } from '@/components/documents/NewDocument' import { Attachments } from '@/components/files/Attachments' import { ChatLazy } from '@/components/chat/ChatLazy' import { loadChatView } from '@/lib/chat/load' import { listAttachments } from '@/lib/files/service' import { toAttachmentViews } from '@/lib/files/view' import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle' import { evaluateEntryEdit, canRetract } from '@/lib/cases/entryPolicy' import Link from 'next/link' import type { EffectivePermissions } from '@/lib/auth/permissions' export const dynamic = 'force-dynamic' export async function generateMetadata({ params, }: { params: Promise<{ caseNumber: string }> }): Promise { const { caseNumber } = await params return { title: caseNumber } } /** * Der Verlauf hängt in einer eigenen Suspense-Grenze und streamt nach. Die * Kopfdaten stehen sofort, der Nutzer liest schon, während der Rest lädt. * Siehe docs/09-performance-und-ux.md. */ async function Timeline({ caseId, caseNumber, permissions, }: { caseId: string caseNumber: string permissions: EffectivePermissions }) { const settings = await db.appSetting.findUnique({ where: { id: 1 } }) const policy = { entryEditPolicy: (settings?.entryEditPolicy ?? 'GRACE_WINDOW') as | 'APPEND_ONLY' | 'GRACE_WINDOW' | 'OPEN', entryEditGraceMinutes: settings?.entryEditGraceMinutes ?? 15, entryEditMarkGrace: settings?.entryEditMarkGrace ?? false, } const entries = await db.caseEntry.findMany({ where: { AND: [visibleEntryWhere(permissions), { caseId }] }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], take: 100, select: { id: true, type: true, body: true, minClearance: true, authorId: true, createdAt: true, version: true, editedAt: true, editVisible: true, retractedAt: true, retractionReason: true, }, }) if (entries.length === 0) { return

{de.entry.empty}

} const authors = await db.user.findMany({ where: { id: { in: [...new Set(entries.map((e) => e.authorId))] } }, select: { id: true, displayName: true }, }) const nameOf = new Map(authors.map((a) => [a.id, a.displayName])) return (
    {entries.map((entry) => (
  1. {de.entry.types[entry.type]} {nameOf.get(entry.authorId) ?? entry.authorId} {entry.editVisible && entry.editedAt ? ( {de.entry.edited(entry.version)} ) : null}
    {entry.retractedAt ? (

    {de.entry.retracted( entry.retractedAt.toLocaleDateString('de-DE'), nameOf.get(entry.authorId) ?? entry.authorId, entry.retractionReason ?? '', )}

    ) : (

    {entry.body}

    )}
  2. ))}
) } /** * Beteiligte, Verknüpfungen und Dokumente hängen in einer gemeinsamen * Suspense-Grenze: drei kleine Abfragen, die zusammen einen Block ergeben. * Getrennt zu streamen liesse den Kasten dreimal springen. */ async function Relations({ akte, permissions, canEdit, }: { akte: { id: string; caseNumber: string; classification: number } permissions: EffectivePermissions canEdit: boolean }) { const [participants, links, documents, anhaenge, templates, levels, members, files] = await Promise.all([ listParticipants(permissions, akte.id), listLinks(permissions, akte.id), listDocuments(permissions, { caseId: akte.id }), listAttachments(permissions, { caseId: akte.id }), canEdit ? listTemplatesFor('CASE_DOCUMENT') : Promise.resolve([]), db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }), canEdit ? db.user.findMany({ orderBy: { displayName: 'asc' }, take: 200, select: { id: true, displayName: true }, }) : Promise.resolve([]), // Nur Personalakten, die der Eintragende sehen darf, und nur, wenn er sie // überhaupt lesen darf: sonst verriete die Auswahlliste ihre Existenz. canEdit && (permissions.isAdmin || permissions.permissions.has('personnel.read')) ? db.personnelFile.findMany({ where: visiblePersonnelWhere(permissions), orderBy: { displayName: 'asc' }, take: 200, select: { id: true, displayName: true }, }) : Promise.resolve([]), ]) const levelName = new Map(levels.map((l) => [l.level, l.name])) return (

{de.participant.heading}

{de.link.heading}

{de.attachment.heading}

{de.document.heading}

{canEdit ? ( l.level <= permissions.clearanceLevel)} defaultLevel={Math.min(akte.classification, permissions.clearanceLevel)} caseNumber={akte.caseNumber} /> ) : null}
) } /** * Der Aktenchat. Eigene Suspense-Grenze, weil er die meisten Abfragen macht * und der Rest der Seite nicht auf ihn warten soll. */ async function ChatBlock({ akte, user, }: { akte: { id: string; caseNumber: string; status: string; classification: number } user: { id: string; permissions: EffectivePermissions } }) { const ansicht = await loadChatView(user.permissions, { kind: 'case', caseNumber: akte.caseNumber, }) return ( ) } async function SectionBlock({ caseId, caseNumber, templateId, permissions, canEdit, }: { caseId: string caseNumber: string templateId: string permissions: EffectivePermissions canEdit: boolean }) { const sections = await loadSections(permissions, caseId, templateId) return ( ) } export default async function CasePage({ params, }: { params: Promise<{ caseNumber: string }> }) { const user = await requireUser() const { caseNumber } = await params const akte = await loadVisibleCase(user.permissions, caseNumber) // 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: 'case.read', targetType: 'Case', targetId: akte.id, clearanceAtTime: user.permissions.clearanceLevel, meta: { caseNumber: akte.caseNumber, classification: akte.classification }, }) } const levels = await db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }) const level = levels.find((l) => l.level === akte.classification) return (
{akte.caseNumber}

{akte.title}

{de.case.status}
{de.case.statusNames[akte.status]}
{de.case.type}
{akte.templateKey}
{akte.dueAt ? (
{de.case.dueAt}
{akte.dueAt.toLocaleDateString('de-DE')}
) : null}
({ level: l.level, name: l.name }))} maxLevel={user.permissions.clearanceLevel} />

{de.section.heading}

} >
} >
} >
} >
) }