From 9a672158be3862ec22002c9b724f9efafbde281d 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. --- .../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 + 8 files changed, 318 insertions(+), 227 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/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',