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> }) { 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 (

{de.search.heading}

{de.search.countLabel(gesamt)}
{ergebnis && 'facets' in ergebnis ? (
({ ...f, label: de.case.statusNames[f.value] ?? f.value, }))} param="status" filters={filters} /> ({ ...f, label: f.value }))} param="templateKey" filters={filters} /> ({ ...f, label: `${f.value} · ${levelName.get(Number(f.value)) ?? ''}`, }))} param="classification" filters={filters} />
) : null} {ergebnis?.truncated ? (

{de.search.truncated}

) : null} {alle ? ( gesamt === 0 ? (

{filters.q ? de.search.empty : de.search.emptyNoQuery}

) : ( // 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.
{([ ['case', faelle], ['document', dokumente], ['personnel', personen], ] as const).map(([kind, teil]) => !teil || teil.hits.length === 0 ? null : (

{de.search.kinds[kind]}

{teil.total > teil.hits.length ? ( {de.search.showAllOfKind(de.search.kinds[kind]!)} {teil.total} ) : null}
    {teil.hits.map((treffer) => (
  • ))}
), )}
) ) : ergebnis!.hits.length === 0 ? (

{filters.q ? de.search.empty : de.search.emptyNoQuery}

{/* Wo sonst noch etwas liegt. Der leere Reiter allein liesse den Eindruck, es gebe zu diesem Begriff nichts. */}
{SEARCH_KINDS.filter( (kind) => kind !== filters.kind && (zahlen[kind] ?? 0) > 0, ).map((kind) => ( {de.search.elsewhere(de.search.kinds[kind]!, zahlen[kind]!)} ))}
) : (
    {ergebnis!.hits.map((treffer) => (
  • ))}
)} {ergebnis && 'cursor' in ergebnis && ergebnis.cursor ? (

{de.search.more}

) : null}
) } 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 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 = 'flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 pl-3 text-text no-underline' if ('caseNumber' in treffer) { return ( {treffer.caseNumber} {treffer.title} {beteiligung && beteiligung.length > 0 ? ( {beteiligung .map((eintrag) => de.search.asParticipant( eintrag.name, de.participant.roles[eintrag.role] ?? eintrag.role, ), ) .join(' · ')} ) : null} {de.case.statusNames[treffer.status] ?? treffer.status} ) } if ('documentNumber' in treffer) { return ( {treffer.documentNumber} {treffer.title} {de.document.statusNames[treffer.status] ?? treffer.status} ) } return ( {treffer.displayName} {treffer.rank ? ( {treffer.rank} ) : null} ) } 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 }) { if (werte.length === 0) return null return (

{label}

    {werte.slice(0, 6).map((wert) => { const aktiv = String(filters[param] ?? '') === wert.value return (
  • {wert.label} {wert.count}
  • ) })}
) }