diff --git a/TASKS.md b/TASKS.md
index a4409ee..49a7335 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -131,8 +131,8 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau.
- [x] Freigabeverfahren ab Stufe 3
- [x] Einzeldokument als PDF, ohne Verlauf, Aussehen aus der Vorlage
- [x] ZIP-Sammelexport und JSON-Vollexport für Admins
-- [ ] Entscheidung offen: Fallakte aufteilen, das Bündelbudget ist aufgebraucht
- (119,8 von 120 kB) — siehe docs/13-entscheidungen.md
+- [x] Fallakte in Reiter geteilt, Bündelbudget wieder eingehalten
+ (116,2 statt 119,8 von 120 kB)
## Phase 9 — Schliff
diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md
index 3dd0d7b..53aa1b8 100644
--- a/docs/13-entscheidungen.md
+++ b/docs/13-entscheidungen.md
@@ -811,11 +811,22 @@ Die Fallakte liegt bei **119,8 kB** gegen 120 erlaubte. In dieser Phase kostete
Verschieben der Exporttexte in eine eigene i18n-Datei den Unterschied zwischen 120,3 und
119,8 — der Export selbst hat eine eigene Seite und trägt nichts zur Fallakte bei.
-Damit ist Schluss mit dem Kürzen: die nächste Insel auf dieser Seite reisst das Budget, und es
-gibt nichts Offensichtliches mehr wegzunehmen. Der nächste Schritt ist, die Seite zu teilen —
-Beteiligte, Verknüpfungen, Dokumente und Anhänge gehören fachlich zusammen und könnten hinter
-einen Reiter oder auf eine eigene Unterseite. Das ist eine Änderung an der Bedienung und
-sollte deshalb bewusst entschieden werden, nicht nebenbei beim nächsten Feature.
+Damit war Schluss mit dem Kürzen. Die Seite ist deshalb in **Reiter** geteilt worden, und zwar
+in eigene Seiten, nicht in umgeschaltete Blöcke: nur so wird der Code getrennt geladen. Ein
+Reiter, der im Client nur ein- und ausgeblendet wird, kostet dasselbe wie vorher.
+
+| Reiter | Inhalt | Bündel |
+|---|---|---|
+| Akte | Abschnitte, Verlauf, Aktenchat | 116,2 kB |
+| Beteiligte | Beteiligte, Verknüpfungen | 110,7 kB |
+| Dokumente | Dokumente, Anhänge | 110,9 kB |
+
+Kopfdaten, Stempel und die Knöpfe für Status und Umklassifizierung stehen im gemeinsamen
+Layout und damit auf jedem Reiter.
+
+Der Schnitt folgt der Fachlichkeit, nicht dem Bündel: die drei Ebenen aus
+`docs/05-akten-und-vorlagen.md` — Kopfdaten, Abschnitte, Verlauf — gehören zusammen und
+bleiben zusammen. Das Umfeld der Akte braucht man selten gleichzeitig mit dem Sachstand.
## Ein Verweis in der Datenbank ist kein Beweis für eine Datei
diff --git a/web/app/(app)/faelle/[caseNumber]/aenderungen/page.tsx b/web/app/(app)/faelle/[caseNumber]/aenderungen/page.tsx
index deab90c..c7c6db0 100644
--- a/web/app/(app)/faelle/[caseNumber]/aenderungen/page.tsx
+++ b/web/app/(app)/faelle/[caseNumber]/aenderungen/page.tsx
@@ -1,5 +1,4 @@
import type { Metadata } from 'next'
-import Link from 'next/link'
import { notFound } from 'next/navigation'
import { db } from '@/lib/db'
import { requireUser } from '@/lib/auth/current'
@@ -41,11 +40,10 @@ export default async function ChangesPage({
)
return (
-
-
- {akte.caseNumber}
-
-
{de.entry.changesHeading}
+ <>
+ {/* Aktenzeichen und Titel stehen im Layout, hier nur noch die Überschrift
+ des Reiters. */}
+
{de.entry.changesHeading}
{de.entry.changesIntro}
{versionen.length === 0 ? (
@@ -94,6 +92,6 @@ export default async function ChangesPage({
))}
)}
-
+ >
)
}
diff --git a/web/app/(app)/faelle/[caseNumber]/beteiligte/page.tsx b/web/app/(app)/faelle/[caseNumber]/beteiligte/page.tsx
new file mode 100644
index 0000000..ea86ea8
--- /dev/null
+++ b/web/app/(app)/faelle/[caseNumber]/beteiligte/page.tsx
@@ -0,0 +1,84 @@
+import type { Metadata } from 'next'
+import { notFound } from 'next/navigation'
+import { db } from '@/lib/db'
+import { requireUser } from '@/lib/auth/current'
+import { visiblePersonnelWhere } from '@/lib/access/personnelFilter'
+import { canEditCase } from '@/lib/access/caseWrite'
+import { loadVisibleCase } from '@/lib/cases/service'
+import { listParticipants } from '@/lib/cases/participants'
+import { listLinks } from '@/lib/cases/links'
+import { de } from '@/lib/i18n/de'
+import { Participants } from '../Participants'
+import { CaseLinks } from '../CaseLinks'
+
+export const metadata: Metadata = { title: de.case.tabRelations }
+export const dynamic = 'force-dynamic'
+
+/**
+ * Wer und was an dieser Akte hängt.
+ *
+ * Eigener Reiter, weil beides selten zugleich mit dem Sachstand gebraucht wird
+ * und die Fallakte sonst das Bündelbudget reisst. Siehe das Layout daneben.
+ */
+export default async function CaseRelationsPage({
+ params,
+}: {
+ params: Promise<{ caseNumber: string }>
+}) {
+ const user = await requireUser()
+ const { caseNumber } = await params
+
+ const akte = await loadVisibleCase(user.permissions, caseNumber)
+ if (!akte) notFound()
+
+ const canEdit = canEditCase(user.permissions, akte).ok
+ const darfPersonal =
+ user.permissions.isAdmin || user.permissions.permissions.has('personnel.read')
+
+ const [participants, links, members, files] = await Promise.all([
+ listParticipants(user.permissions, akte.id),
+ listLinks(user.permissions, akte.id),
+ canEdit
+ ? db.user.findMany({
+ where: { isActive: true },
+ orderBy: { displayName: 'asc' },
+ take: 200,
+ select: { id: true, displayName: true },
+ })
+ : Promise.resolve([]),
+ // Nur Personalakten, die der Eintragende sehen darf, und nur mit dem Recht
+ // dazu: sonst verriete die Auswahlliste ihre Existenz.
+ canEdit && darfPersonal
+ ? db.personnelFile.findMany({
+ where: visiblePersonnelWhere(user.permissions),
+ orderBy: { displayName: 'asc' },
+ take: 200,
+ select: { id: true, displayName: true },
+ })
+ : Promise.resolve([]),
+ ])
+
+ return (
+
+
+
{de.participant.heading}
+
+
+
+
+
+
+
{de.link.heading}
+
+
+
+
+
+ )
+}
diff --git a/web/app/(app)/faelle/[caseNumber]/dokumente/page.tsx b/web/app/(app)/faelle/[caseNumber]/dokumente/page.tsx
new file mode 100644
index 0000000..637051c
--- /dev/null
+++ b/web/app/(app)/faelle/[caseNumber]/dokumente/page.tsx
@@ -0,0 +1,75 @@
+import type { Metadata } from 'next'
+import { notFound } from 'next/navigation'
+import { db } from '@/lib/db'
+import { requireUser } from '@/lib/auth/current'
+import { canEditCase } from '@/lib/access/caseWrite'
+import { loadVisibleCase } from '@/lib/cases/service'
+import { listDocuments, listTemplatesFor } from '@/lib/documents/service'
+import { listAttachments } from '@/lib/files/service'
+import { toAttachmentViews } from '@/lib/files/view'
+import { de } from '@/lib/i18n/de'
+import { DocumentList } from '@/components/documents/DocumentList'
+import { NewDocument } from '@/components/documents/NewDocument'
+import { Attachments } from '@/components/files/Attachments'
+
+export const metadata: Metadata = { title: de.document.heading }
+export const dynamic = 'force-dynamic'
+
+/**
+ * Was der Akte beiliegt: erzeugte Dokumente und hochgeladene Dateien.
+ */
+export default async function CaseDocumentsPage({
+ params,
+}: {
+ params: Promise<{ caseNumber: string }>
+}) {
+ const user = await requireUser()
+ const { caseNumber } = await params
+
+ const akte = await loadVisibleCase(user.permissions, caseNumber)
+ if (!akte) notFound()
+
+ const canEdit = canEditCase(user.permissions, akte).ok
+
+ const [documents, anhaenge, templates, levels] = await Promise.all([
+ listDocuments(user.permissions, { caseId: akte.id }),
+ listAttachments(user.permissions, { caseId: akte.id }),
+ canEdit ? listTemplatesFor('CASE_DOCUMENT') : Promise.resolve([]),
+ db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
+ ])
+
+ return (
+
+ )
+}
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 (
-