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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
291 lines
8.7 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|