„Keine Textzentrierung" - die Beobachtung stimmte an einundzwanzig Stellen, und die Ursache war überall dieselbe: die Zeilen waren Flex-Behälter, in denen jedes Element so breit war wie sein Inhalt. Damit fing der Titel in jeder Zeile woanders an, und die Stempelmarken hatten nur rechts eine gemeinsame Kante. Bei „ETHIK" und „WS" untereinander sieht man es sofort. Die Breiten stehen jetzt einmal in globals.css - record-key, record-label, record-title, record-meta, stamp-mark - und sind über Variablen je Liste anpassbar: eine Liste von Abteilungskürzeln braucht keine vierzehn Zeichen, eine Liste von Aktenzeichen schon. Zwei Dinge, die dabei mehr sind als Kosmetik: Eine Spalte wird auch reserviert, wenn sie leer ist. Vorher hing der Dienstgrad an einer Bedingung, und bei einer Person ohne Rang rutschte alles Folgende nach links. Was keine Kennung ist, gehört nicht in die Kennungsspalte. In „Zuletzt geöffnet" stand der Name einer Person dort, wo sonst das Aktenzeichen steht: in Mono, abgeschnitten, und der Dienstgrad wanderte an die Stelle des Titels. Für eine Person ist der Name der Titel. Die Stempelmarke ist überall gleich breit; ein zu langer Name wird gekürzt, die Stufenziffer bleibt stehen - sie ist die eigentliche Auskunft. Unter 40 rem fallen die festen Breiten weg, sonst liefen sie über. Nachgesehen im Browser mit dem gebauten CSS, Fallakten, Abteilungen, Zuletzt geöffnet und Beteiligte.
417 lines
14 KiB
TypeScript
417 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-title">{treffer.displayName}</span>
|
|
{treffer.rank ? (
|
|
<span className="font-condensed text-2xs text-text-dim">{treffer.rank}</span>
|
|
) : null}
|
|
<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>
|
|
)
|
|
}
|