Files
AfiS-Aktensystem/web/app/(app)/faelle/[caseNumber]/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

291 lines
8.7 KiB
TypeScript

import type { Metadata } from 'next'
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import Link from 'next/link'
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/write'
import { de } from '@/lib/i18n/de'
import { SkeletonLine } from '@/components/Skeleton'
import { EntryComposer } from './EntryComposer'
import { Sections } from '@/components/sections/Sections'
import { saveSectionAction, saveSectionsAction } from './sectionActions'
import { loadSections } from '@/lib/cases/sectionService'
import { canEditCase } from '@/lib/access/caseWrite'
import { EntryActions } from './EntryActions'
import { ChatLazy } from '@/components/chat/ChatLazy'
import { loadChatView } from '@/lib/chat/load'
import { evaluateEntryEdit, canRetract } from '@/lib/cases/entryPolicy'
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 }
}
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
scope={`fall:${caseNumber}`}
save={saveSectionAction.bind(null, caseNumber)}
saveAll={saveSectionsAction.bind(null, caseNumber)}
sections={sections}
canEdit={canEdit}
/>
)
}
/**
* 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 (
<ChatLazy
scope={{ kind: 'case', caseNumber: akte.caseNumber, caseId: akte.id }}
topic={`case:${akte.id}`}
initial={ansicht.messages}
initialReactions={ansicht.reactions}
currentUserId={user.id}
lastReadAt={ansicht.lastReadAt}
canAdopt={canEditCase(user.permissions, akte, 'entry.create').ok}
mentionNames={ansicht.mentionNames}
/>
)
}
/**
* Der Reiter „Akte": die drei Ebenen aus docs/05-akten-und-vorlagen.md, die
* zusammengehören - Abschnitte als Sachstand, Verlauf als Chronologie, dazu
* der formlose Aktenchat. Kopfdaten und Reiterleiste stehen im Layout.
*/
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 },
})
}
return (
<>
<section>
<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>
<section className="mt-12">
<div className="flex flex-wrap items-baseline justify-between gap-4">
<h2 className="font-condensed">{de.case.timeline}</h2>
<Link href={`/faelle/${akte.caseNumber}/aenderungen`} className="text-xs">
{de.case.changes}
</Link>
</div>
<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>
<section className="mt-12">
<Suspense
fallback={
<div className="space-y-3 border border-line p-4">
<SkeletonLine width="8rem" />
<SkeletonLine width="70%" />
<SkeletonLine width="55%" />
</div>
}
>
<ChatBlock akte={akte} user={user} />
</Suspense>
</section>
</>
)
}