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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
151 lines
5.2 KiB
TypeScript
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>
|
|
)
|
|
}
|