+ )
+}
diff --git a/web/app/(app)/faelle/[caseNumber]/export/page.tsx b/web/app/(app)/faelle/[caseNumber]/export/page.tsx
index 2224df7..05fc3fb 100644
--- a/web/app/(app)/faelle/[caseNumber]/export/page.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/export/page.tsx
@@ -29,10 +29,8 @@ export default async function CaseExportPage({
if (!akte) notFound()
return (
-
-
{akte.caseNumber}
-
{de.export.heading}
-
+
+
{de.export.heading}
{de.export.coverNote}
@@ -46,10 +44,9 @@ export default async function CaseExportPage({
{de.export.waitingApprovalHint}
) : null}
-
-
+
+
)
}
diff --git a/web/app/(app)/faelle/[caseNumber]/layout.tsx b/web/app/(app)/faelle/[caseNumber]/layout.tsx
new file mode 100644
index 0000000..3bb451c
--- /dev/null
+++ b/web/app/(app)/faelle/[caseNumber]/layout.tsx
@@ -0,0 +1,100 @@
+import type { ReactNode } from 'react'
+import Link from 'next/link'
+import { notFound } from 'next/navigation'
+import { db } from '@/lib/db'
+import { requireUser } from '@/lib/auth/current'
+import { loadVisibleCase } from '@/lib/cases/service'
+import { de } from '@/lib/i18n/de'
+import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
+import { allowedTargets, type CaseStatusName } from '@/lib/cases/lifecycle'
+import { CaseActions } from './CaseActions'
+
+export const dynamic = 'force-dynamic'
+
+/**
+ * Kopf und Reiter einer Fallakte.
+ *
+ * Die Akte ist in Reiter geteilt, und zwar in **eigene Seiten**, nicht in
+ * umgeschaltete Blöcke: nur so wird der Code getrennt geladen. Die Fallakte
+ * war die dichteste Seite der Anwendung und hatte das Bündelbudget aus
+ * docs/09-performance-und-ux.md aufgebraucht — Beteiligte, Verknüpfungen,
+ * Dokumente und Anhänge lagen mit auf der Seite, obwohl man sie selten
+ * gleichzeitig braucht.
+ *
+ * Fachlich passt der Schnitt ohnehin: die drei Ebenen aus
+ * docs/05-akten-und-vorlagen.md — Kopfdaten, Abschnitte, Verlauf — bleiben
+ * zusammen, das Umfeld der Akte bekommt seinen eigenen Platz.
+ */
+export default async function CaseLayout({
+ children,
+ params,
+}: {
+ children: ReactNode
+ 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()
+
+ const levels = await db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } })
+ const level = levels.find((l) => l.level === akte.classification)
+
+ const reiter = [
+ { href: '', label: de.case.tabFile },
+ { href: '/beteiligte', label: de.case.tabRelations },
+ { href: '/dokumente', label: de.case.tabDocuments },
+ { href: '/aenderungen', label: de.case.changes },
+ { href: '/export', label: de.export.heading },
+ ]
+
+ 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}
+
+
+ {/* Statuswechsel und Umklassifizierung gehören zum Kopf und stehen
+ deshalb auf jedem Reiter zur Verfügung. */}
+ ({ level: l.level, name: l.name }))}
+ maxLevel={user.permissions.clearanceLevel}
+ />
+
+
+
+
+
{children}
+
+ )
+}
diff --git a/web/app/(app)/faelle/[caseNumber]/page.tsx b/web/app/(app)/faelle/[caseNumber]/page.tsx
index 37075fc..132bfa9 100644
--- a/web/app/(app)/faelle/[caseNumber]/page.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/page.tsx
@@ -1,37 +1,23 @@
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'
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'
@@ -45,11 +31,6 @@ export async function generateMetadata({
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,
@@ -146,102 +127,31 @@ async function Timeline({
)
}
-/**
- * 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,
+async function SectionBlock({
+ caseId,
+ caseNumber,
+ templateId,
permissions,
canEdit,
}: {
- akte: { id: string; caseNumber: string; classification: number }
+ caseId: string
+ caseNumber: string
+ templateId: string
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]))
-
+ const sections = await loadSections(permissions, caseId, templateId)
return (
-