Eine Vorlage ist eine geordnete Liste von Bausteinen: Eingabefelder und feste Textbausteine mit Platzhaltern. Damit trägt dieselbe Struktur den Vorfallsbericht und den Sonderarbeitsvertrag. Die Startvorlagen bilden jetzt den echten Bericht der Abteilung ab, samt Berichtsart, Gefahreneinschätzung und Foundation-Freigabe - letztere als gewöhnliches Feld ohne Wirkung auf den Zugriff. Abschnitte werden gerendert und bearbeitet, Entwürfe alle zwei Sekunden lokal gesichert. Gespeichert wird auf Knopfdruck: ein Abschnitt ist der Sachstand der Akte, kein Chatbeitrag. Die geladene Version geht mit; weicht sie ab, gibt es kein stilles Überschreiben, sondern beide Fassungen nebeneinander mit "Meine übernehmen" und "Fremde übernehmen". Felder über der Stufe des Lesers werden serverseitig durch einen Platzhalter ersetzt, Schwärzungen im Fliesstext kommen als Segmente. Beides so, dass der echte Wert den Server nicht verlässt - nicht als Ausblenden im Client. Der Wortdiff ist selbst geschrieben statt eingekauft: ein Dutzend Zeilen gegen 30 kB im Bündel für etwas, das nur serverseitig läuft. Beim Bauen des Vorlagen-Editors riss das Bündel-Budget auf 127,9 kB, weil zod über das Vorlagenmodul in den Client geriet. Typen und Validierung sind jetzt getrennt: fields.ts ohne zod für Client und Server, schema.ts nur serverseitig. Wieder bei 111,9 kB von 120. 85 Tests, 22 E2E.
273 lines
8.9 KiB
TypeScript
273 lines
8.9 KiB
TypeScript
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 './Sections'
|
|
import { loadSections } from '@/lib/cases/sectionService'
|
|
import { canEditCase } from '@/lib/access/caseWrite'
|
|
import { CaseActions } from './CaseActions'
|
|
import { EntryActions } from './EntryActions'
|
|
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<Metadata> {
|
|
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 <p className="text-text-dim">{de.entry.empty}</p>
|
|
}
|
|
|
|
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 (
|
|
<ol className="timeline">
|
|
{entries.map((entry) => (
|
|
<li key={entry.id} className="pb-6">
|
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xs text-text-dim">
|
|
<span className="font-condensed">{de.entry.types[entry.type]}</span>
|
|
<span className="font-mono">{nameOf.get(entry.authorId) ?? entry.authorId}</span>
|
|
<time dateTime={entry.createdAt.toISOString()}>
|
|
{entry.createdAt.toLocaleString('de-DE')}
|
|
</time>
|
|
{entry.editVisible && entry.editedAt ? (
|
|
<Link
|
|
href={`/faelle/${caseNumber}/aenderungen`}
|
|
className="font-condensed"
|
|
>
|
|
{de.entry.edited(entry.version)}
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
|
|
{entry.retractedAt ? (
|
|
<p className="mt-1 border-l-(length:--spacing-edge) border-line pl-3 text-sm text-text-dim">
|
|
{de.entry.retracted(
|
|
entry.retractedAt.toLocaleDateString('de-DE'),
|
|
nameOf.get(entry.authorId) ?? entry.authorId,
|
|
entry.retractionReason ?? '',
|
|
)}
|
|
</p>
|
|
) : (
|
|
<p className="mt-1 max-w-(--container-measure) whitespace-pre-wrap text-sm">
|
|
{entry.body}
|
|
</p>
|
|
)}
|
|
|
|
<EntryActions
|
|
entryId={entry.id}
|
|
caseNumber={caseNumber}
|
|
body={entry.body}
|
|
retracted={entry.retractedAt !== null}
|
|
canEdit={evaluateEntryEdit(permissions, entry, policy).ok}
|
|
canRetract={canRetract(permissions, entry)}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)
|
|
}
|
|
|
|
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 <Sections caseNumber={caseNumber} sections={sections} canEdit={canEdit} />
|
|
}
|
|
|
|
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 (
|
|
<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-mono text-xs text-text-dim">{akte.caseNumber}</span>
|
|
<StampMark level={akte.classification} name={level?.name ?? de.common.unknown} />
|
|
</div>
|
|
<h1 className="mt-2 font-condensed">{akte.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.case.statusNames[akte.status]}</dd>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<dt className="font-condensed">{de.case.type}</dt>
|
|
<dd>{akte.templateKey}</dd>
|
|
</div>
|
|
{akte.dueAt ? (
|
|
<div className="flex gap-2">
|
|
<dt className="font-condensed">{de.case.dueAt}</dt>
|
|
<dd>{akte.dueAt.toLocaleDateString('de-DE')}</dd>
|
|
</div>
|
|
) : null}
|
|
</dl>
|
|
<CaseActions
|
|
caseNumber={akte.caseNumber}
|
|
classification={akte.classification}
|
|
statusTargets={allowedTargets(akte.status as CaseStatusName)}
|
|
levels={levels.map((l) => ({ level: l.level, name: l.name }))}
|
|
maxLevel={user.permissions.clearanceLevel}
|
|
/>
|
|
</header>
|
|
|
|
<section className="mt-10">
|
|
<h2 className="font-condensed">{de.section.heading}</h2>
|
|
<div className="mt-4">
|
|
<Suspense
|
|
fallback={
|
|
<div className="space-y-6">
|
|
<SkeletonLine width="10rem" />
|
|
<SkeletonLine />
|
|
<SkeletonLine width="12rem" />
|
|
<SkeletonLine />
|
|
</div>
|
|
}
|
|
>
|
|
<SectionBlock
|
|
caseId={akte.id}
|
|
caseNumber={akte.caseNumber}
|
|
templateId={akte.templateId}
|
|
permissions={user.permissions}
|
|
canEdit={canEditCase(user.permissions, akte).ok}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
|
|
<nav className="mt-10 flex gap-4 border-b border-line pb-2 font-condensed text-xs">
|
|
<span className="text-text-dim">{de.case.timeline}</span>
|
|
<Link href={`/faelle/${akte.caseNumber}/aenderungen`}>{de.case.changes}</Link>
|
|
</nav>
|
|
|
|
<section className="mt-6">
|
|
<div className="mt-4">
|
|
<EntryComposer caseNumber={akte.caseNumber} />
|
|
</div>
|
|
|
|
<div className="mt-8">
|
|
<Suspense
|
|
fallback={
|
|
<div className="timeline">
|
|
<SkeletonLine width="18rem" />
|
|
<SkeletonLine />
|
|
<SkeletonLine width="80%" />
|
|
<div className="h-4" />
|
|
<SkeletonLine width="16rem" />
|
|
<SkeletonLine width="70%" />
|
|
</div>
|
|
}
|
|
>
|
|
<Timeline
|
|
caseId={akte.id}
|
|
caseNumber={akte.caseNumber}
|
|
permissions={user.permissions}
|
|
/>
|
|
</Suspense>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
)
|
|
}
|