From 77b29d68d316c9121a13eb3f57120dae66876abf Mon Sep 17 00:00:00 2001 From: Harlekin Date: Thu, 3 Sep 2026 10:15:14 +0200 Subject: [PATCH] Fallakte in Reiter geteilt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Akte, Beteiligte, Dokumente, Änderungen, Export — als eigene Seiten unter einem gemeinsamen Layout, nicht als umgeschaltete Blöcke. Ein Reiter, der im Client nur ein- und ausgeblendet wird, kostet dasselbe wie vorher; nur eigene Seiten trennen den Code wirklich. Der Schnitt folgt der Fachlichkeit: die drei Ebenen aus docs/05 — Kopfdaten, Abschnitte, Verlauf — bleiben zusammen, samt Aktenchat. Das Umfeld der Akte braucht man selten gleichzeitig mit dem Sachstand. Kopf, Stempel und die Knöpfe für Status und Umklassifizierung stehen im Layout und damit auf jedem Reiter. Grösste Route 116,2 statt 119,8 von 120 kB, die neuen Reiter bei 110,7 und 110,9. Damit ist wieder Luft für Phase 9. 48 e2e-Tests, 178 im Web-Paket unverändert grün. Co-Authored-By: Claude Opus 5 --- TASKS.md | 4 +- docs/13-entscheidungen.md | 21 +- .../faelle/[caseNumber]/aenderungen/page.tsx | 12 +- .../faelle/[caseNumber]/beteiligte/page.tsx | 84 ++++++ .../faelle/[caseNumber]/dokumente/page.tsx | 75 +++++ .../(app)/faelle/[caseNumber]/export/page.tsx | 13 +- web/app/(app)/faelle/[caseNumber]/layout.tsx | 100 +++++++ web/app/(app)/faelle/[caseNumber]/page.tsx | 256 +++--------------- web/e2e/smoke.spec.ts | 2 + web/lib/i18n/cases.ts | 3 + 10 files changed, 336 insertions(+), 234 deletions(-) create mode 100644 web/app/(app)/faelle/[caseNumber]/beteiligte/page.tsx create mode 100644 web/app/(app)/faelle/[caseNumber]/dokumente/page.tsx create mode 100644 web/app/(app)/faelle/[caseNumber]/layout.tsx 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 ( +
+
+

{de.document.heading}

+
+ [l.level, l.name]))} + /> + {canEdit ? ( + l.level <= user.permissions.clearanceLevel)} + defaultLevel={Math.min(akte.classification, user.permissions.clearanceLevel)} + caseNumber={akte.caseNumber} + /> + ) : null} +
+
+ +
+

{de.attachment.heading}

+
+ +
+
+
+ ) +} 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 ( -
-
-

{de.participant.heading}

-
- -
-
- -
-

{de.link.heading}

-
- -
-
- -
-

{de.attachment.heading}

-
- -
-
- -
-

{de.document.heading}

-
- - {canEdit ? ( - l.level <= permissions.clearanceLevel)} - defaultLevel={Math.min(akte.classification, permissions.clearanceLevel)} - caseNumber={akte.caseNumber} - /> - ) : null} -
-
-
+ ) } + /** * Der Aktenchat. Eigene Suspense-Grenze, weil er die meisten Abfragen macht * und der Rest der Seite nicht auf ihn warten soll. @@ -272,30 +182,11 @@ async function ChatBlock({ ) } -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 ( - - ) -} - +/** + * 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, }: { @@ -320,46 +211,9 @@ export default async function CasePage({ }) } - const levels = await db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }) - const level = levels.find((l) => l.level === akte.classification) - 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} -
- ({ level: l.level, name: l.name }))} - maxLevel={user.permissions.clearanceLevel} - /> -
- -
+ <> +

{de.section.heading}

-
- -
- - -
-
- - -
-
- } - > - - - -
- - - - - - } - > - - -
+
+

{de.case.timeline}

+ + {de.case.changes} + +
- - -
@@ -452,6 +270,20 @@ export default async function CasePage({
-
+ +
+ + + + + + } + > + + +
+ ) } diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts index 19839d8..b411c67 100644 --- a/web/e2e/smoke.spec.ts +++ b/web/e2e/smoke.spec.ts @@ -36,6 +36,8 @@ test.describe('Geschuetzte Bereiche', () => { '/kanaele', '/suche', '/faelle/AfiS-2026-0001/export', + '/faelle/AfiS-2026-0001/beteiligte', + '/faelle/AfiS-2026-0001/dokumente', '/einstellungen', '/dokumente/AfiS-2026-0001', '/admin/rollen', diff --git a/web/lib/i18n/cases.ts b/web/lib/i18n/cases.ts index a076e55..e682ddc 100644 --- a/web/lib/i18n/cases.ts +++ b/web/lib/i18n/cases.ts @@ -24,6 +24,9 @@ export const caseText = { sections: 'Abschnitte', chat: 'Aktenchat', changes: 'Änderungen', + tabFile: 'Akte', + tabRelations: 'Beteiligte', + tabDocuments: 'Dokumente', participants: 'Beteiligte', links: 'Verknüpfungen', attachments: 'Anhänge',