Files
AfiS-Aktensystem/web/app/(app)/faelle/page.tsx
T
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

151 lines
5.2 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 { CASE_STATUSES } from '@/lib/cases/terms'
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-2">
<Link href="/faelle" aria-current={status ? undefined : 'page'} className="btn btn-sm">
{de.case.filterAll}
</Link>
{/* Alle sieben Zustände, nicht fünf: „Entwurf" und „Versiegelt" fehlten,
und das waren ausgerechnet die beiden, die in der Liste am häufigsten
standen - man konnte nach dem filtern, was man sah, gerade nicht.
Versiegelt nur für den, der Versiegeltes überhaupt lesen darf. */}
{CASE_STATUSES.filter(
(value) =>
value !== 'SEALED' ||
user.permissions.isAdmin ||
user.permissions.clearanceLevel >= 5,
).map((value) => (
<Link
key={value}
href={`/faelle?status=${value}`}
aria-current={status === value ? 'page' : undefined}
title={de.case.statusHints[value]}
className="btn btn-sm"
>
{de.case.statusNames[value]}
</Link>
))}
{user.permissions.isAdmin || user.permissions.permissions.has('case.create') ? (
<Link href="/faelle/neu" className="ml-auto btn btn-sm btn-primary">
{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="record-row py-3 pl-3 text-text no-underline"
style={classificationEdgeStyle(akte.classification)}
>
<span className="record-key">{akte.caseNumber}</span>
<span className="record-title">{akte.title}</span>
<span className="record-meta">{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>
)
}