Files
AfiS-Aktensystem/web/app/(app)/suche/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

415 lines
14 KiB
TypeScript

import type { Metadata } from 'next'
import Link from 'next/link'
import { db } from '@/lib/db'
import { requireUser } from '@/lib/auth/current'
import {
parseFilters,
toSearchParams,
SEARCH_KINDS,
SEARCH_SCOPES,
} from '@/lib/search/query'
import {
PREVIEW_SIZE,
matchingParticipants,
searchCases,
searchCounts,
searchDocuments,
searchPersonnel,
} from '@/lib/search/service'
import { builtinViews } from '@/lib/search/views'
import { de } from '@/lib/i18n/de'
import { StampMark, classificationEdgeStyle } from '@/components/StampMark'
import { StatusTags } from '@/components/StatusTags'
import { SearchBox } from './SearchBox'
import { SavedViews } from './SavedViews'
export const metadata: Metadata = { title: de.search.heading }
export const dynamic = 'force-dynamic'
/**
* Suchseite.
*
* Alles läuft über Adressparameter: das macht jeden Zustand teilbar, den
* Zurück-Knopf richtig und eine gespeicherte Ansicht zu dem, was sie ist -
* ein Lesezeichen. Siehe lib/search/query.ts.
*/
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const user = await requireUser()
const roh = await searchParams
const filters = parseFilters(
Object.fromEntries(
Object.entries(roh).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]),
),
)
const [levels, gespeichert] = await Promise.all([
db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }),
db.savedView.findMany({
where: { userId: user.id },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
select: { id: true, name: true, query: true },
}),
])
const levelName = new Map(levels.map((l) => [l.level, l.name]))
const alle = filters.kind === 'all'
// In der gemeinsamen Ansicht je Art nur eine Handvoll: dort geht es darum,
// **wo** etwas liegt. Der Weg in die volle Liste steht daneben.
const stueck = alle ? { take: PREVIEW_SIZE } : {}
const [faelle, dokumente, personen, zahlen] = await Promise.all([
alle || filters.kind === 'case' ? searchCases(user.permissions, filters, stueck) : null,
alle || filters.kind === 'document'
? searchDocuments(user.permissions, filters, stueck)
: null,
alle || filters.kind === 'personnel'
? searchPersonnel(user.permissions, filters, stueck)
: null,
// Die Zahlen aller Arten. Ohne sie sucht man nach einem Namen, landet auf
// den Fallakten und erfährt nie, dass es dazu auch eine Personalakte gibt.
searchCounts(user.permissions, filters),
])
// Bei einer einzelnen Art hängt alles Weitere an ihr: Facetten, Blättern,
// die Trefferzahl in der Kopfzeile. Bei „Alle" gibt es das nicht - dort ist
// jede Art ein eigener Block mit eigenem Weg in die Vollansicht.
const ergebnis = alle ? null : (faelle ?? dokumente ?? personen)!
const gesamt = alle
? zahlen.case + zahlen.document + (zahlen.personnel ?? 0)
: (ergebnis?.total ?? 0)
const statusTags =
alle || filters.kind === 'personnel' ? await listStatusTags() : []
// Warum ein Fall zu einem Namen passt: seit die Beteiligten im Index stehen,
// trifft die Suche auch Akten, in denen der Name nirgends im Text steht.
const beteiligungen = await matchingParticipants(
(faelle?.hits ?? []).map((treffer) => treffer.id),
filters.q,
)
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.search.heading}</h1>
<span className="record-key">
{de.search.countLabel(gesamt)}
</span>
</div>
<SearchBox filters={filters} />
<nav className="mt-4 flex flex-wrap gap-2">
{SEARCH_SCOPES.map((kind) => {
const anzahl = kind === 'all' ? gesamt : zahlen[kind]
// `null` heisst: diese Art gibt es für diesen Nutzer nicht. Dann
// fehlt der Reiter, statt eine Null zu zeigen - auch eine Null wäre
// eine Auskunft.
if (anzahl === null) return null
return (
<Link
key={kind}
href={`/suche?${toSearchParams({ ...filters, kind, cursor: undefined })}`}
aria-current={kind === filters.kind ? 'page' : undefined}
className="btn btn-sm"
>
{de.search.kinds[kind]}
<span className="btn-count">{anzahl}</span>
</Link>
)
})}
</nav>
<SavedViews
builtins={builtinViews()}
saved={gespeichert}
currentQuery={toSearchParams({ ...filters, cursor: undefined })}
/>
{ergebnis && 'facets' in ergebnis ? (
<div className="mt-6 flex flex-wrap gap-x-8 gap-y-3 border-t border-line pt-4 text-2xs">
<Facette
label={de.case.status}
werte={ergebnis.facets.status.map((f) => ({
...f,
label: de.case.statusNames[f.value] ?? f.value,
}))}
param="status"
filters={filters}
/>
<Facette
label={de.case.type}
werte={ergebnis.facets.templateKey.map((f) => ({ ...f, label: f.value }))}
param="templateKey"
filters={filters}
/>
<Facette
label={de.clearance.classification}
werte={ergebnis.facets.classification.map((f) => ({
...f,
label: `${f.value} · ${levelName.get(Number(f.value)) ?? ''}`,
}))}
param="classification"
filters={filters}
/>
</div>
) : null}
{ergebnis?.truncated ? (
<p className="mt-4 text-2xs text-stamp-3">{de.search.truncated}</p>
) : null}
{alle ? (
gesamt === 0 ? (
<p className="mt-10 text-text-dim">
{filters.q ? de.search.empty : de.search.emptyNoQuery}
</p>
) : (
// Bei „Alle" ist jede Art ein eigener Block. Zusammengeworfen wäre
// die Liste nach Rang sortiert und damit ohne erkennbare Ordnung:
// ein Aktenzeichen neben einem Namen neben einer Dokumentnummer.
<div className="mt-8 space-y-10">
{([
['case', faelle],
['document', dokumente],
['personnel', personen],
] as const).map(([kind, teil]) =>
!teil || teil.hits.length === 0 ? null : (
<section key={kind}>
<div className="flex flex-wrap items-baseline justify-between gap-4">
<h2 className="font-condensed text-xs text-text-dim">
{de.search.kinds[kind]}
</h2>
{teil.total > teil.hits.length ? (
<Link
href={`/suche?${toSearchParams({ ...filters, kind, cursor: undefined })}`}
className="btn btn-sm"
>
{de.search.showAllOfKind(de.search.kinds[kind]!)}
<span className="btn-count">{teil.total}</span>
</Link>
) : null}
</div>
<ul className="mt-3 border-t border-line">
{teil.hits.map((treffer) => (
<li key={treffer.id} className="border-b border-line">
<Treffer
treffer={treffer}
levelName={levelName}
statusTags={statusTags}
beteiligung={beteiligungen.get(treffer.id)}
/>
</li>
))}
</ul>
</section>
),
)}
</div>
)
) : ergebnis!.hits.length === 0 ? (
<div className="mt-10">
<p className="text-text-dim">
{filters.q ? de.search.empty : de.search.emptyNoQuery}
</p>
{/* Wo sonst noch etwas liegt. Der leere Reiter allein liesse den
Eindruck, es gebe zu diesem Begriff nichts. */}
<div className="mt-3 flex flex-wrap gap-2">
{SEARCH_KINDS.filter(
(kind) => kind !== filters.kind && (zahlen[kind] ?? 0) > 0,
).map((kind) => (
<Link
key={kind}
href={`/suche?${toSearchParams({ ...filters, kind, cursor: undefined })}`}
className="btn btn-sm btn-primary"
>
{de.search.elsewhere(de.search.kinds[kind]!, zahlen[kind]!)}
</Link>
))}
</div>
</div>
) : (
<ul className="mt-6 border-t border-line">
{ergebnis!.hits.map((treffer) => (
<li key={treffer.id} className="border-b border-line">
<Treffer
treffer={treffer}
levelName={levelName}
statusTags={statusTags}
beteiligung={beteiligungen.get(treffer.id)}
/>
</li>
))}
</ul>
)}
{ergebnis && 'cursor' in ergebnis && ergebnis.cursor ? (
<p className="mt-6">
<Link
href={`/suche?${toSearchParams({ ...filters, cursor: ergebnis.cursor })}`}
className="font-condensed text-xs"
>
{de.search.more}
</Link>
</p>
) : null}
</main>
)
}
type TrefferView =
| { id: string; caseNumber: string; title: string; status: string; classification: number }
| { id: string; documentNumber: string; title: string; status: string; classification: number }
| {
id: string
displayName: string
rank: string | null
statusTags: string[]
classification: number
}
/**
* Eine Trefferzeile.
*
* Welche Art es ist, steht an der Form: ein Treffer mit Aktenzeichen ist eine
* Fallakte. Das ist kein Trick, sondern der Grund, warum die drei Suchen
* getrennte Rückgaben haben - ein gemeinsamer Typ mit lauter optionalen
* Feldern liesse offen, was wirklich dasteht.
*/
function Treffer({
treffer,
levelName,
statusTags,
beteiligung,
}: {
treffer: TrefferView
levelName: Map<number, string>
statusTags: { key: string; name: string; colorToken: string }[]
/** Gesetzt, wenn der Treffer an einem Beteiligten hängt und nicht am Text. */
beteiligung?: { name: string; role: string }[]
}) {
const zeile = 'record-row py-3 pl-3 text-text no-underline'
if ('caseNumber' in treffer) {
return (
<Link
href={`/faelle/${treffer.caseNumber}`}
prefetch
className={zeile}
style={classificationEdgeStyle(treffer.classification)}
>
<span className="record-key">{treffer.caseNumber}</span>
<span className="record-title">
{treffer.title}
{beteiligung && beteiligung.length > 0 ? (
<span className="mt-0.5 block text-2xs text-text-dim">
{beteiligung
.map((eintrag) =>
de.search.asParticipant(
eintrag.name,
de.participant.roles[eintrag.role] ?? eintrag.role,
),
)
.join(' · ')}
</span>
) : null}
</span>
<span className="font-condensed text-2xs text-text-dim">
{de.case.statusNames[treffer.status] ?? treffer.status}
</span>
<StampMark
level={treffer.classification}
name={levelName.get(treffer.classification) ?? de.common.unknown}
/>
</Link>
)
}
if ('documentNumber' in treffer) {
return (
<Link
href={`/dokumente/${treffer.documentNumber}`}
prefetch
className={zeile}
style={classificationEdgeStyle(treffer.classification)}
>
<span className="record-key">{treffer.documentNumber}</span>
<span className="record-title">{treffer.title}</span>
<span className="font-condensed text-2xs text-text-dim">
{de.document.statusNames[treffer.status] ?? treffer.status}
</span>
</Link>
)
}
return (
<Link
href={`/personalakten/${treffer.id}`}
prefetch
className={zeile}
style={classificationEdgeStyle(treffer.classification)}
>
<span className="record-rank">{treffer.rank ? `[${treffer.rank}]` : ''}</span>
<span className="record-title">{treffer.displayName}</span>
<StatusTags tags={treffer.statusTags} known={statusTags} />
</Link>
)
}
async function listStatusTags() {
return db.personnelStatusTag.findMany({
where: { isActive: true },
orderBy: { sortOrder: 'asc' },
select: { key: true, name: true, colorToken: true },
})
}
/**
* Eine Facette. Die Zahlen stammen aus derselben Abfrage wie die Liste - ein
* Zähler, der mehr zählt als die Liste zeigt, wäre selbst eine Information.
*/
function Facette({
label,
werte,
param,
filters,
}: {
label: string
werte: { value: string; label: string; count: number }[]
param: 'status' | 'templateKey' | 'classification'
filters: ReturnType<typeof parseFilters>
}) {
if (werte.length === 0) return null
return (
<div>
<p className="font-condensed text-text-dim">{label}</p>
<ul className="mt-1 space-y-0.5">
{werte.slice(0, 6).map((wert) => {
const aktiv = String(filters[param] ?? '') === wert.value
return (
<li key={wert.value}>
<Link
href={`/suche?${toSearchParams({
...filters,
[param]: aktiv ? undefined : wert.value,
cursor: undefined,
})}`}
className={aktiv ? 'text-accent' : 'text-text'}
>
{wert.label} <span className="font-mono text-text-dim">{wert.count}</span>
</Link>
</li>
)
})}
</ul>
</div>
)
}