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',