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.
415 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|