Suche fängt bei „Alle" an, Bereiche in der Kopfzeile sind Knöpfe

**Suche.** Sie startete auf den Fallakten, und man musste die Art vorher
wählen - die falsche Reihenfolge: wer einen Namen eintippt, weiss noch nicht,
ob er eine Fallakte, ein Dokument oder eine Personalakte sucht. Er will wissen,
wo der Name vorkommt.

„Alle" ist jetzt der Auslieferungszustand und zeigt je Art fünf Treffer als
eigenen Block, mit dem Weg in die volle Liste daneben. Getrennte Blöcke statt
einer gemischten Liste nach Rang: ein Aktenzeichen neben einem Namen neben
einer Dokumentnummer hat keine erkennbare Ordnung. Die einzelnen Arten bleiben
als Filter, und nur dort gibt es Facetten und Blättern - beides bezieht sich
auf eine Grundmenge, ein Statusfilter über Fall- und Personalakten zugleich
wäre bei der einen ein Filter und bei der anderen ein Ausschluss.

**Kopfzeile.** Die Bereiche waren Textlinks: von einem Fliesstextlink nicht zu
unterscheiden und ohne Hinweis darauf, wo man gerade ist. Jetzt dieselbe
Knopfform wie alles Bedienbare, der aktive Bereich gefüllt.

Der gefüllte Zustand trägt hellen Text statt der Akzentfarbe: auf der getönten
Fläche wäre Akzent auf Akzent und der Kontrast fiele unter die Untergrenze.
Dazu etwas grosszügigere Polsterung an beiden Knopfgrössen, .btn-count für
Zahlen in Knöpfen, und kein Doppelring mehr am Profilbild.

Nachgesehen mit dem gebauten CSS im Browser, hell und dunkel.
This commit is contained in:
Harlekin
2026-09-03 21:59:06 +02:00
parent d49edeed18
commit 4b794ec9a8
8 changed files with 304 additions and 92 deletions
+14 -9
View File
@@ -3,6 +3,7 @@ import { signOut } from '@/auth'
import { requireUser } from '@/lib/auth/current'
import { de } from '@/lib/i18n/de'
import { PaletteTrigger } from '@/components/palette/PaletteTrigger'
import { NavLinks } from '@/components/NavLinks'
import { ProfileMenu } from '@/components/ProfileMenu'
import { Avatar } from '@/components/Avatar'
import { SearchBar } from '@/components/SearchBar'
@@ -40,15 +41,19 @@ export default async function AppLayout({ children }: { children: React.ReactNod
{de.app.shortName}
</Link>
<nav className="flex flex-wrap gap-4 font-condensed text-xs">
<Link href="/uebersicht">{de.nav.dashboard}</Link>
<Link href="/faelle">{de.nav.cases}</Link>
{darfPersonal ? <Link href="/personalakten">{de.nav.personnel}</Link> : null}
<Link href="/abteilungen">{de.nav.departments}</Link>
{hasAdminAccess(user.permissions) ? (
<Link href="/admin">{de.nav.admin}</Link>
) : null}
</nav>
<NavLinks
links={[
{ href: '/uebersicht', label: de.nav.dashboard },
{ href: '/faelle', label: de.nav.cases },
...(darfPersonal
? [{ href: '/personalakten', label: de.nav.personnel }]
: []),
{ href: '/abteilungen', label: de.nav.departments },
...(hasAdminAccess(user.permissions)
? [{ href: '/admin', label: de.nav.admin }]
: []),
]}
/>
<div className="ml-auto flex items-center gap-3">
<SearchBar />
+170 -66
View File
@@ -2,8 +2,14 @@ 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 } from '@/lib/search/query'
import {
parseFilters,
toSearchParams,
SEARCH_KINDS,
SEARCH_SCOPES,
} from '@/lib/search/query'
import {
PREVIEW_SIZE,
searchCases,
searchCounts,
searchDocuments,
@@ -49,34 +55,50 @@ export default async function SearchPage({
])
const levelName = new Map(levels.map((l) => [l.level, l.name]))
const [ergebnis, zahlen] = await Promise.all([
filters.kind === 'document'
? searchDocuments(user.permissions, filters)
: filters.kind === 'personnel'
? searchPersonnel(user.permissions, filters)
: searchCases(user.permissions, filters),
// Die Zahlen der anderen Arten. Ohne sie sucht man nach einem Namen, landet
// auf den Fallakten und erfährt nie, dass es dazu auch eine Personalakte
// gibt — der Reiter sieht leer aus wie jeder andere.
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),
])
const statusTags = filters.kind === 'personnel' ? await listStatusTags() : []
// 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() : []
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="font-mono text-xs text-text-dim">
{de.search.countLabel(ergebnis.total)}
{de.search.countLabel(gesamt)}
</span>
</div>
<SearchBox filters={filters} />
<nav className="mt-4 flex flex-wrap gap-2">
{SEARCH_KINDS.map((kind) => {
const anzahl = zahlen[kind]
{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.
@@ -90,7 +112,7 @@ export default async function SearchPage({
className="btn btn-sm"
>
{de.search.kinds[kind]}
<span className="font-mono text-text-dim">{anzahl}</span>
<span className="btn-count">{anzahl}</span>
</Link>
)
})}
@@ -102,7 +124,7 @@ export default async function SearchPage({
currentQuery={toSearchParams({ ...filters, cursor: undefined })}
/>
{'facets' in ergebnis ? (
{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}
@@ -131,11 +153,54 @@ export default async function SearchPage({
</div>
) : null}
{ergebnis.truncated ? (
{ergebnis?.truncated ? (
<p className="mt-4 text-2xs text-stamp-3">{de.search.truncated}</p>
) : null}
{ergebnis.hits.length === 0 ? (
{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} />
</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}
@@ -158,60 +223,15 @@ export default async function SearchPage({
</div>
) : (
<ul className="mt-6 border-t border-line">
{ergebnis.hits.map((treffer) => (
{ergebnis!.hits.map((treffer) => (
<li key={treffer.id} className="border-b border-line">
{'caseNumber' in treffer ? (
<Link
href={`/faelle/${treffer.caseNumber}`}
prefetch
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 pl-3 text-text no-underline"
style={classificationEdgeStyle(treffer.classification)}
>
<span className="font-mono text-xs text-text-dim">{treffer.caseNumber}</span>
<span className="grow">{treffer.title}</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>
) : 'documentNumber' in treffer ? (
<Link
href={`/dokumente/${treffer.documentNumber}`}
prefetch
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 pl-3 text-text no-underline"
style={classificationEdgeStyle(treffer.classification)}
>
<span className="font-mono text-xs text-text-dim">
{treffer.documentNumber}
</span>
<span className="grow">{treffer.title}</span>
<span className="font-condensed text-2xs text-text-dim">
{de.document.statusNames[treffer.status] ?? treffer.status}
</span>
</Link>
) : (
<Link
href={`/personalakten/${treffer.id}`}
prefetch
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 pl-3 text-text no-underline"
style={classificationEdgeStyle(treffer.classification)}
>
<span className="grow">{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>
)}
<Treffer treffer={treffer} levelName={levelName} statusTags={statusTags} />
</li>
))}
</ul>
)}
{'cursor' in ergebnis && ergebnis.cursor ? (
{ergebnis && 'cursor' in ergebnis && ergebnis.cursor ? (
<p className="mt-6">
<Link
href={`/suche?${toSearchParams({ ...filters, cursor: ergebnis.cursor })}`}
@@ -225,6 +245,90 @@ export default async function SearchPage({
)
}
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,
}: {
treffer: TrefferView
levelName: Map<number, string>
statusTags: { key: string; name: string; colorToken: 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 (
<Link
href={`/faelle/${treffer.caseNumber}`}
prefetch
className={zeile}
style={classificationEdgeStyle(treffer.classification)}
>
<span className="font-mono text-xs text-text-dim">{treffer.caseNumber}</span>
<span className="grow">{treffer.title}</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="font-mono text-xs text-text-dim">{treffer.documentNumber}</span>
<span className="grow">{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="grow">{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 },
+31 -4
View File
@@ -240,7 +240,7 @@
border-radius: var(--radius-control);
background-color: var(--color-raised);
color: var(--color-text);
padding: 0.3rem 0.85rem;
padding: 0.35rem 0.95rem;
font-family: var(--font-condensed);
font-size: var(--text-xs);
line-height: 1.45;
@@ -291,7 +291,7 @@
/* Fuer dichte Zeilen: Etikettenleisten, Werkzeuge an einem Eintrag. */
.btn-sm {
padding: 0.15rem 0.6rem;
padding: 0.22rem 0.75rem;
font-size: var(--text-2xs);
}
@@ -299,9 +299,30 @@
Reaktion gesetzt oder nicht, Reiter offen oder nicht. */
.btn[aria-current='page'],
.btn[aria-pressed='true'] {
background-color: color-mix(in srgb, var(--color-accent) 18%, var(--color-raised));
/* Der gefuellte Zustand traegt hellen Text, nicht die Akzentfarbe: auf der
getoenten Flaeche waere Akzent auf Akzent, und der Kontrast faellt unter
die Untergrenze aus docs/10-design-system.md. */
background-color: color-mix(in srgb, var(--color-accent) 26%, var(--color-raised));
border-color: var(--color-accent);
color: var(--color-accent);
color: var(--color-text);
}
.btn[aria-current='page']:hover,
.btn[aria-pressed='true']:hover {
color: var(--color-text);
background-color: color-mix(in srgb, var(--color-accent) 34%, var(--color-raised));
}
/* Die Zahl in einem Knopf: eine Angabe, kein Teil der Beschriftung. Sie
sitzt deshalb ruhiger und in Mono. */
.btn-count {
font-family: var(--font-mono);
font-size: 0.9em;
color: var(--color-text-dim);
}
.btn[aria-current='page'] .btn-count {
color: var(--color-text);
}
/* Waehrend eine Aktion laeuft. Kein Spinner, nur ein ruhiger Zustand:
@@ -393,6 +414,12 @@
padding-block: 0.35rem;
}
/* Steckt das Bild in einem Knopf, traegt der Knopf den Rahmen. Zwei
ineinander waeren ein Doppelring. */
.btn > .avatar {
border: 0;
}
/* Das Profilbild oben rechts und ueberall sonst, wo eine Person auftaucht. */
.avatar {
display: inline-flex;