Files
AfiS-Aktensystem/web/app/(app)/suche/page.tsx
T
Harlekin 18c9a71fda Export ohne Vier-Augen, Wiedervorlage setzbar, Druckansicht mit Weg zurück
**Export.** Das Vier-Augen-Verfahren ab Stufe 3 ist weg. Wer exportiert,
entscheidet jetzt allein die Freigabestufe: mindestens die des Ziels. Das ist
strenger als das Lesen - wer als Beteiligter ausnahmsweise in eine höher
eingestufte Akte darf, darf sie deshalb noch nicht ausser Haus geben.

Dabei fiel eine Lücke auf: hängte man sich an einen laufenden Auftrag,
entstand kein Protokolleintrag. „Jeder Export wird protokolliert" heisst jeder
Abruf, nicht jeder Rendervorgang - wer dieselbe Datei bekommt, muss genauso
darin stehen.

**Fristen.** dueAt wurde überall gelesen - Übersicht, Suchfilter, Ausdruck -
und nirgends gesetzt. Es gab keine Stelle, an der man ein Datum einträgt,
deshalb stand unter „Fristen" immer „Nichts steht an". Jetzt im Kopf der Akte,
mit Protokolleintrag und ohne Verlaufseintrag: eine Wiedervorlage ist eine
Merkhilfe, keine Aussage über den Fall.

**Statuswechsel.** Aus einem Entwurf führt laut Lebenszyklus nur „Offen"
heraus, deshalb standen dort nie mehr als zwei Ziele. Das sah aus wie ein
Fehler und war keiner - jetzt steht die Erklärung daneben und an jeder Option.

**Druckansicht.** Sie hatte kein eigenes Layout: das weisse Blatt lag auf dem
dunklen Thema und es gab keinen Weg zurück. Jetzt heller Grund, eine Leiste
mit Zurück und Drucken, beides beim Drucken ausgeblendet. Das Ziel wird aus
dem Pfad abgeleitet, damit es auch beim direkten Aufruf stimmt.

**Abteilungsstatistik.** Eine Abteilung ohne Freigabe wird als „Ohne Freigabe"
geführt statt weggelassen - sonst summierten sich die Anteile nicht auf die
Zahl daneben. Mehrere ergeben ein Stück, sonst stünde dort, wie viele es sind.

Dazu: Dienstgrad in eckigen Klammern vor dem Namen und in dessen Grösse, in
einer eigenen Spalte; Knopfform für „Personalakte anlegen"; die abgeschnittene
Anzahl im Kopf der Personalaktenliste; „Zuletzt geöffnet" ohne die zwei
Hinweistexte, leer als Zeichen statt als Satz.
2026-09-04 00:14:07 +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>
)
}