Files
AfiS-Aktensystem/web/app/(app)/faelle/page.tsx
T
Harlekin 651f239c68 Phase 2, erster Teil: Akten anlegen, lesen, Vermerke schreiben
Fachlogik in lib/cases/service.ts, Zugriffsprüfung in lib/access/. Jede
Funktion prüft selbst und schreibt ihren Protokolleintrag in derselben
Transaktion wie die Änderung — die Server Actions daneben sind dünn.

Schreibgrenzen nach docs/03: niemand legt eine Akte über der eigenen Stufe an,
niemand stuft über die eigene Stufe hoch, archiviert und versiegelt sind
schreibgeschützt auch für Admins. Eine Herabstufung verlangt eine Begründung
und landet als sichtbarer Systemeintrag im Verlauf, nicht nur im Protokoll.

Aktenzeichen über CaseCounter mit INSERT ... ON CONFLICT DO UPDATE, geprüft mit
acht gleichzeitigen Anlagen ohne Doppelung. Wer eine Akte anlegt, wird ihr
Ermittler, sonst verliert er sie aus dem Blick, sobald jemand hochstuft.

Fallliste cursorbasiert auf (updatedAt, id), Sichtbarkeit und Trefferzahl in
derselben Abfrage. Aktenansicht mit Kopfdaten sofort und Verlauf in eigener
Suspense-Grenze. Vermerke optimistisch mit clientseitiger UUIDv7, die zugleich
Idempotenzschlüssel ist — zweimal gesendet bleibt ein Eintrag.

Lesezugriffe ab Stufe 3 werden protokolliert, gepuffert und alle zwei Sekunden
gebündelt geschrieben, damit das Protokoll die Antwort nicht ausbremst.

Die vier Startvorlagen stehen jetzt im Seed statt in Phase 3: ohne Vorlage
lässt sich keine Akte anlegen.
2026-09-01 22:57:38 +02:00

135 lines
4.5 KiB
TypeScript

import type { Metadata } from 'next'
import Link from 'next/link'
import { db } from '@/lib/db'
import { requireUser } from '@/lib/auth/current'
import { visibleCaseWhere } from '@/lib/access/caseFilter'
import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
export const metadata: Metadata = { title: de.case.listHeading }
export const dynamic = 'force-dynamic'
const PAGE_SIZE = 25
/**
* Fallliste.
*
* Sichtbarkeit und Trefferzahl laufen über **dieselbe** Abfrage. Würde man
* nachfiltern, stimmten Zählung und Seitenlänge nicht mehr, und aus den Lücken
* liesse sich auf verborgene Akten schliessen. Siehe docs/03-auth-und-rechte.md.
*
* Blättern über einen Cursor auf `(updatedAt, id)`, kein OFFSET: die Liste
* bleibt stabil, auch wenn währenddessen Akten dazukommen.
*/
export default async function CaseListPage({
searchParams,
}: {
searchParams: Promise<{ status?: string; cursor?: string }>
}) {
const user = await requireUser()
const { status, cursor } = await searchParams
const sichtbar = visibleCaseWhere(user.permissions)
const statusFilter = status && status !== 'alle' ? { status: status as never } : {}
const cursorFilter = cursor
? (() => {
const [at, id] = cursor.split('_')
if (!at || !id) return {}
const date = new Date(Number(at))
return {
OR: [{ updatedAt: { lt: date } }, { updatedAt: date, id: { lt: id } }],
}
})()
: {}
const where = { AND: [sichtbar, statusFilter, cursorFilter] }
const [cases, total, levels] = await Promise.all([
db.case.findMany({
where,
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
take: PAGE_SIZE + 1,
select: {
id: true,
caseNumber: true,
title: true,
status: true,
classification: true,
updatedAt: true,
},
}),
db.case.count({ where: { AND: [sichtbar, statusFilter] } }),
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
])
const hasMore = cases.length > PAGE_SIZE
const page = hasMore ? cases.slice(0, PAGE_SIZE) : cases
const last = page.at(-1)
const levelName = new Map(levels.map((l) => [l.level, l.name]))
return (
<main className="mx-auto max-w-5xl px-6 py-10">
<div className="flex flex-wrap items-baseline justify-between gap-4">
<h1 className="font-condensed">{de.case.listHeading}</h1>
<span className="font-mono text-xs text-text-dim">{de.case.countLabel(total)}</span>
</div>
<nav className="mt-4 flex flex-wrap gap-3 font-condensed text-xs">
<Link href="/faelle">{de.case.filterAll}</Link>
{(['OPEN', 'REVIEW', 'DORMANT', 'CLOSED', 'ARCHIVED'] as const).map((value) => (
<Link key={value} href={`/faelle?status=${value}`}>
{de.case.statusNames[value]}
</Link>
))}
{user.permissions.isAdmin || user.permissions.permissions.has('case.create') ? (
<Link href="/faelle/neu" className="ml-auto">
{de.case.create}
</Link>
) : null}
</nav>
{page.length === 0 ? (
<p className="mt-10 text-text-dim">{de.case.empty}</p>
) : (
<ul className="mt-6 border-t border-line">
{page.map((akte) => (
<li key={akte.id} className="border-b border-line">
<Link
href={`/faelle/${akte.caseNumber}`}
prefetch
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 pl-3 text-text no-underline"
style={classificationEdgeStyle(akte.classification)}
>
<span className="font-mono text-xs text-text-dim">{akte.caseNumber}</span>
<span className="grow">{akte.title}</span>
<span className="font-condensed text-2xs text-text-dim">
{de.case.statusNames[akte.status]}
</span>
<StampMark
level={akte.classification}
name={levelName.get(akte.classification) ?? de.common.unknown}
/>
</Link>
</li>
))}
</ul>
)}
{hasMore && last ? (
<p className="mt-6">
<Link
href={`/faelle?${new URLSearchParams({
...(status ? { status } : {}),
cursor: `${last.updatedAt.getTime()}_${last.id}`,
})}`}
className="font-condensed text-xs"
>
{de.case.more}
</Link>
</p>
) : null}
</main>
)
}