Vier Sachen aus der Durchsicht. **Der Statusfilter zeigte fünf von sieben Zuständen.** DRAFT und SEALED fehlten - ausgerechnet die beiden, die in der Liste am häufigsten standen; man konnte nach dem, was man sah, gerade nicht filtern. Das erklärt auch die Frage, was „In Prüfung" und „Ruhend" bedeuten: die Zustände standen nirgends in der Oberfläche. Jetzt alle sieben in der Reihenfolge des Lebenszyklus, mit einer Erklärung als title, und Versiegelt nur für den, der Versiegeltes lesen darf. Ein Test verlangt zu jedem Zustand einen Namen und eine Erklärung. **Beliebig viele Beteiligte beim Anlegen.** Das Formular kannte genau eine „betroffene Person", und die Rolle war auf SUBJECT festgelegt. Jetzt beliebig viele Zeilen mit je einer Rolle, Konten und Personalakten in einer Auswahl, wie auf der Aktenseite. Die Zerlegung steht als parseParticipantDrafts in lib/cases/terms.ts und nicht in der Server Action: seit die Rolle über den Zugriff entscheidet, darf eine erfundene Rolle nicht durchrutschen. **Die Filterleisten sind Knöpfe** wie überall sonst, mit gefülltem aktivem Filter. **Keine leere Kennungsspalte mehr.** In „Zuletzt geöffnet" stand bei einer Person nichts, wo sonst das Aktenzeichen steht - jetzt steht dort „Personalakte". Nebenbei zwei Stellen zurückgenommen, an denen die Spaltenklasse im Seitenkopf gelandet war statt in einer Zeile.
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>
|
|
)
|
|
}
|