Die letzte offene Zeile aus TASKS.md. Sie blieb offen, weil eine Spur über
jeden Aktenaufruf schnell etwas anderes wird als eine Bequemlichkeit.
Vier Festlegungen halten sie auf der richtigen Seite:
- Kein Verlauf. Der Primärschlüssel (userId, targetType, targetId) lässt je
Nutzer und Datensatz genau eine Zeile zu, die überschrieben wird. Die
Tabelle kann nicht sagen, wie oft oder in welcher Reihenfolge gearbeitet
wurde. Zwanzig Einträge, älteres fällt hinten heraus.
- Nur die eigene. Keine Ansicht, kein Adminbereich, keine Funktion mit
fremder Kennung. Jede Abfrage nimmt p.userId.
- Jederzeit löschbar, ohne Rückfrage und ohne Grabstein.
- Vorabrufe zählen nicht. Ohne die Prüfung auf next-router-prefetch stünde
dort, worüber jemand mit der Maus gefahren ist.
Gelesen wird über dieselben Sichtbarkeitsfragmente wie überall: eine
vorgemerkte Kennung ist keine Berechtigung. Steigt eine Akte über die
Freigabestufe, verschwindet sie ohne Lücke und ohne Platzhalter; Personalakten
brauchen zusätzlich personnel.read. Das Zugriffsprotokoll bleibt unberührt und
bleibt die einzige Auskunft darüber, wer wann was gelesen hat.
Sie steht auf der Übersicht und im leeren Feld der Kommandopalette. Der zweite
Ort ist der eigentliche.
Nebenbei: NEXT_DIST_DIR, damit ein Build neben einem laufenden
Entwicklungsserver nicht dasselbe .next-Verzeichnis abräumt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
4.3 KiB
TypeScript
112 lines
4.3 KiB
TypeScript
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'
|
|
import { WatchToggle } from './WatchToggle'
|
|
import { isWatching } from '@/lib/cases/watch'
|
|
import { trackOpen } from '@/lib/recent/track'
|
|
|
|
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()
|
|
|
|
// Für die eigene Sprungliste vormerken. Steht im Layout und nicht auf einem
|
|
// Reiter: wer die Beteiligten aufruft, hat die Akte geöffnet.
|
|
await trackOpen(user.id, 'CASE', akte.id)
|
|
|
|
const [levels, beobachtet] = await Promise.all([
|
|
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
|
|
isWatching(user.permissions, akte.id),
|
|
])
|
|
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 (
|
|
<main className="mx-auto max-w-5xl px-6 py-10">
|
|
<header className="pl-3" style={classificationEdgeStyle(akte.classification)}>
|
|
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
|
|
<span className="font-mono text-xs text-text-dim">{akte.caseNumber}</span>
|
|
<StampMark level={akte.classification} name={level?.name ?? de.common.unknown} />
|
|
<WatchToggle caseNumber={akte.caseNumber} initial={beobachtet} />
|
|
</div>
|
|
<h1 className="mt-2 font-condensed">{akte.title}</h1>
|
|
<dl className="mt-3 flex flex-wrap gap-x-8 gap-y-1 text-xs text-text-dim">
|
|
<div className="flex gap-2">
|
|
<dt className="font-condensed">{de.case.status}</dt>
|
|
<dd>{de.case.statusNames[akte.status]}</dd>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<dt className="font-condensed">{de.case.type}</dt>
|
|
<dd>{akte.templateKey}</dd>
|
|
</div>
|
|
{akte.dueAt ? (
|
|
<div className="flex gap-2">
|
|
<dt className="font-condensed">{de.case.dueAt}</dt>
|
|
<dd>{akte.dueAt.toLocaleDateString('de-DE')}</dd>
|
|
</div>
|
|
) : null}
|
|
</dl>
|
|
|
|
{/* Statuswechsel und Umklassifizierung gehören zum Kopf und stehen
|
|
deshalb auf jedem Reiter zur Verfügung. */}
|
|
<CaseActions
|
|
caseNumber={akte.caseNumber}
|
|
classification={akte.classification}
|
|
statusTargets={allowedTargets(akte.status as CaseStatusName)}
|
|
levels={levels.map((l) => ({ level: l.level, name: l.name }))}
|
|
maxLevel={user.permissions.clearanceLevel}
|
|
/>
|
|
</header>
|
|
|
|
<nav className="mt-8 flex flex-wrap gap-4 border-b border-line pb-2 font-condensed text-xs">
|
|
{reiter.map((eintrag) => (
|
|
<Link key={eintrag.href} href={`/faelle/${akte.caseNumber}${eintrag.href}`}>
|
|
{eintrag.label}
|
|
</Link>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="mt-8">{children}</div>
|
|
</main>
|
|
)
|
|
}
|