Suche fängt bei „Alle" an, Bereiche in der Kopfzeile sind Knöpfe
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
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
**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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d867bc9b19
commit
74b52625c8
@@ -14,6 +14,11 @@ Postgres Full-Text-Search, deutsche Konfiguration. Kein Elasticsearch.
|
||||
|
||||
Filter: Status, Fallart, Zeitraum, Beteiligte, Klassifizierung, Tags, nur meine Fälle.
|
||||
|
||||
**Die Suche sucht ohne Angabe über alle Arten.** Wer einen Namen eintippt, will wissen, wo er
|
||||
vorkommt, und nicht zuerst entscheiden, wonach er sucht. Die gemeinsame Ansicht zeigt je Art
|
||||
eine Handvoll Treffer als eigenen Block, mit dem Weg in die volle Liste daneben; die einzelnen
|
||||
Arten bleiben als Filter, und nur dort gibt es Facetten und Blättern.
|
||||
|
||||
**Die Sichtbarkeitsprüfung gehört in dieselbe Abfrage**, nicht als Nachfilter. Sonst stimmen
|
||||
Trefferzahl und Seitenlänge nicht und aus den Lücken lässt sich auf verborgene Akten
|
||||
schließen. Gleiches gilt für Facettenzähler.
|
||||
|
||||
@@ -1213,3 +1213,29 @@ Der Test prüft die Regel und nicht die Zahl: er liest die Polsterung von `.fiel
|
||||
und verlangt, dass Label-Abstand und Reihenabstand darüber liegen. Wer die Polsterung ändert,
|
||||
fällt dort auf. Nachgemessen im Browser mit dem gebauten CSS: 8 px senkrecht gegen 5,6 px
|
||||
Innenpolsterung, 13,6 px waagerecht gegen 11,2 px.
|
||||
|
||||
### Die Suche fängt bei „Alle" an
|
||||
|
||||
Die Suche startete auf den Fallakten, und man musste die Art vorher wählen. Das ist 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.
|
||||
|
||||
`all` ist deshalb der Auslieferungszustand. Die gemeinsame Ansicht zeigt je Art fünf Treffer
|
||||
als eigenen Block, mit dem Weg in die volle Liste daneben. Getrennte Blöcke und nicht eine
|
||||
gemischte Liste nach Rang: ein Aktenzeichen neben einem Namen neben einer Dokumentnummer hat
|
||||
keine erkennbare Ordnung.
|
||||
|
||||
Facetten und Blättern gibt es weiterhin nur bei einer einzelnen Art. Beides bezieht sich auf
|
||||
**eine** Grundmenge — ein Statusfilter über Fallakten und Personalakten zugleich wäre bei der
|
||||
einen Art ein Filter und bei der anderen ein Ausschluss.
|
||||
|
||||
### Die Bereiche in der Kopfzeile sind Knöpfe
|
||||
|
||||
Sie waren Textlinks: von einem Fliesstextlink nicht zu unterscheiden und ohne jeden Hinweis
|
||||
darauf, wo man gerade ist. Jetzt tragen sie dieselbe Knopfform wie alles Bedienbare, und der
|
||||
Bereich, in dem man steht, ist 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 aus docs/10-design-system.md.
|
||||
Aus demselben Grund liegt in einem Knopf steckende Zahl (`.btn-count`) in Mono und gedämpft —
|
||||
sie ist eine Angabe, kein Teil der Beschriftung.
|
||||
|
||||
@@ -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 />
|
||||
|
||||
+149
-45
@@ -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,13 +223,64 @@ 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 ? (
|
||||
<Treffer treffer={treffer} levelName={levelName} statusTags={statusTags} />
|
||||
</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,
|
||||
}: {
|
||||
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="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 pl-3 text-text no-underline"
|
||||
className={zeile}
|
||||
style={classificationEdgeStyle(treffer.classification)}
|
||||
>
|
||||
<span className="font-mono text-xs text-text-dim">{treffer.caseNumber}</span>
|
||||
@@ -177,26 +293,31 @@ export default async function SearchPage({
|
||||
name={levelName.get(treffer.classification) ?? de.common.unknown}
|
||||
/>
|
||||
</Link>
|
||||
) : 'documentNumber' in treffer ? (
|
||||
)
|
||||
}
|
||||
|
||||
if ('documentNumber' in treffer) {
|
||||
return (
|
||||
<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"
|
||||
className={zeile}
|
||||
style={classificationEdgeStyle(treffer.classification)}
|
||||
>
|
||||
<span className="font-mono text-xs text-text-dim">
|
||||
{treffer.documentNumber}
|
||||
</span>
|
||||
<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="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 pl-3 text-text no-underline"
|
||||
className={zeile}
|
||||
style={classificationEdgeStyle(treffer.classification)}
|
||||
>
|
||||
<span className="grow">{treffer.displayName}</span>
|
||||
@@ -205,23 +326,6 @@ export default async function SearchPage({
|
||||
) : null}
|
||||
<StatusTags tags={treffer.statusTags} known={statusTags} />
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{'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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+31
-4
@@ -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;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
/**
|
||||
* Die Bereiche der Anwendung in der Kopfzeile.
|
||||
*
|
||||
* Sie waren blosse Textlinks — von einem Fliesstextlink nicht zu unterscheiden
|
||||
* und ohne Hinweis darauf, wo man gerade ist. Jetzt tragen sie dieselbe
|
||||
* Knopfform wie alles Bedienbare, und der Bereich, in dem man steht, ist
|
||||
* gefüllt.
|
||||
*
|
||||
* Der Vergleich läuft über den Pfad, nicht über die vollständige Adresse: die
|
||||
* Suche bleibt hervorgehoben, während man ihre Filter wechselt.
|
||||
*/
|
||||
export function NavLinks({ links }: { links: { href: string; label: string }[] }) {
|
||||
const pfad = usePathname()
|
||||
|
||||
return (
|
||||
<nav className="flex flex-wrap gap-1.5">
|
||||
{links.map((eintrag) => {
|
||||
const aktiv = pfad === eintrag.href || pfad.startsWith(eintrag.href + '/')
|
||||
return (
|
||||
<Link
|
||||
key={eintrag.href}
|
||||
href={eintrag.href}
|
||||
aria-current={aktiv ? 'page' : undefined}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
{eintrag.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -133,10 +133,12 @@ export const searchText = {
|
||||
heading: 'Suche',
|
||||
placeholder: 'Aktenzeichen, Titel, Inhalt',
|
||||
kinds: {
|
||||
all: 'Alle',
|
||||
case: 'Fallakten',
|
||||
document: 'Dokumente',
|
||||
personnel: 'Personalakten',
|
||||
} as Record<string, string>,
|
||||
showAllOfKind: (label: string) => `Alle ${label} zeigen`,
|
||||
filters: 'Filter',
|
||||
reset: 'Filter zurücksetzen',
|
||||
empty: 'Kein Treffer. Anderen Begriff oder weniger Filter versuchen.',
|
||||
|
||||
+13
-4
@@ -6,12 +6,21 @@
|
||||
* Darstellung benutzt, ist der Punkt — eine Ansicht ist ein Lesezeichen.
|
||||
*/
|
||||
|
||||
/** Die Arten, über die einzeln gesucht werden kann. */
|
||||
export const SEARCH_KINDS = ['case', 'document', 'personnel'] as const
|
||||
export type SearchKind = (typeof SEARCH_KINDS)[number]
|
||||
|
||||
/**
|
||||
* Was in der Adresse stehen kann. `all` ist der Auslieferungszustand: wer
|
||||
* einen Namen eintippt, will wissen, wo er überhaupt vorkommt, und nicht
|
||||
* zuerst entscheiden, wonach er sucht. Die einzelnen Arten bleiben als Filter.
|
||||
*/
|
||||
export const SEARCH_SCOPES = ['all', ...SEARCH_KINDS] as const
|
||||
export type SearchScope = (typeof SEARCH_SCOPES)[number]
|
||||
|
||||
export type SearchFilters = {
|
||||
q: string
|
||||
kind: SearchKind
|
||||
kind: SearchScope
|
||||
status?: string
|
||||
templateKey?: string
|
||||
classification?: number
|
||||
@@ -37,9 +46,9 @@ export function parseFilters(params: URLSearchParams | Record<string, string | u
|
||||
|
||||
return {
|
||||
q: (get('q') ?? '').slice(0, 200),
|
||||
kind: (SEARCH_KINDS as readonly string[]).includes(kind ?? '')
|
||||
? (kind as SearchKind)
|
||||
: 'case',
|
||||
kind: (SEARCH_SCOPES as readonly string[]).includes(kind ?? '')
|
||||
? (kind as SearchScope)
|
||||
: 'all',
|
||||
status: get('status'),
|
||||
templateKey: get('templateKey'),
|
||||
classification: stufe !== undefined && /^[0-5]$/.test(stufe) ? Number(stufe) : undefined,
|
||||
|
||||
@@ -29,6 +29,13 @@ const FTS_LIMIT = 500
|
||||
|
||||
export const PAGE_SIZE = 25
|
||||
|
||||
/**
|
||||
* So viele Treffer je Art zeigt die gemeinsame Ansicht. Weniger als eine volle
|
||||
* Seite: dort geht es darum, **wo** etwas liegt, nicht darum, alles zu lesen.
|
||||
* Der Weg in die volle Liste steht daneben.
|
||||
*/
|
||||
export const PREVIEW_SIZE = 5
|
||||
|
||||
/**
|
||||
* Personalakten setzen neben der Freigabestufe ein eigenes Recht voraus.
|
||||
*
|
||||
@@ -101,7 +108,12 @@ export type CaseHit = {
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export async function searchCases(p: EffectivePermissions, filters: SearchFilters) {
|
||||
export async function searchCases(
|
||||
p: EffectivePermissions,
|
||||
filters: SearchFilters,
|
||||
options: { take?: number } = {},
|
||||
) {
|
||||
const seite = options.take ?? PAGE_SIZE
|
||||
const ids = await matchingIds('case', filters.q, p.clearanceLevel)
|
||||
const where = caseWhere(p, filters, ids)
|
||||
|
||||
@@ -118,7 +130,7 @@ export async function searchCases(p: EffectivePermissions, filters: SearchFilter
|
||||
db.case.findMany({
|
||||
where: { AND: [where, cursorFilter] },
|
||||
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
|
||||
take: PAGE_SIZE + 1,
|
||||
take: seite + 1,
|
||||
select: {
|
||||
id: true,
|
||||
caseNumber: true,
|
||||
@@ -136,8 +148,8 @@ export async function searchCases(p: EffectivePermissions, filters: SearchFilter
|
||||
db.case.groupBy({ by: ['classification'], where, _count: { _all: true } }),
|
||||
])
|
||||
|
||||
const hasMore = treffer.length > PAGE_SIZE
|
||||
const page = hasMore ? treffer.slice(0, PAGE_SIZE) : treffer
|
||||
const hasMore = treffer.length > seite
|
||||
const page = hasMore ? treffer.slice(0, seite) : treffer
|
||||
const last = page.at(-1)
|
||||
|
||||
return {
|
||||
@@ -171,7 +183,11 @@ function documentWhere(
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchDocuments(p: EffectivePermissions, filters: SearchFilters) {
|
||||
export async function searchDocuments(
|
||||
p: EffectivePermissions,
|
||||
filters: SearchFilters,
|
||||
options: { take?: number } = {},
|
||||
) {
|
||||
const ids = await matchingIds('document', filters.q, p.clearanceLevel)
|
||||
const where = documentWhere(p, filters, ids)
|
||||
|
||||
@@ -179,7 +195,7 @@ export async function searchDocuments(p: EffectivePermissions, filters: SearchFi
|
||||
db.document.findMany({
|
||||
where,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: PAGE_SIZE,
|
||||
take: options.take ?? PAGE_SIZE,
|
||||
select: {
|
||||
id: true,
|
||||
documentNumber: true,
|
||||
@@ -212,7 +228,11 @@ function personnelWhere(
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchPersonnel(p: EffectivePermissions, filters: SearchFilters) {
|
||||
export async function searchPersonnel(
|
||||
p: EffectivePermissions,
|
||||
filters: SearchFilters,
|
||||
options: { take?: number } = {},
|
||||
) {
|
||||
// Ohne das Recht gibt es diese Akten für ihn nicht — auch nicht als Zahl.
|
||||
if (!darfPersonalakten(p)) {
|
||||
return { hits: [], total: 0, truncated: false }
|
||||
@@ -225,7 +245,7 @@ export async function searchPersonnel(p: EffectivePermissions, filters: SearchFi
|
||||
db.personnelFile.findMany({
|
||||
where,
|
||||
orderBy: { displayName: 'asc' },
|
||||
take: PAGE_SIZE,
|
||||
take: options.take ?? PAGE_SIZE,
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
|
||||
@@ -192,6 +192,13 @@ suite('Volltextsuche', () => {
|
||||
expect(ohneRecht.total).toBe(0)
|
||||
})
|
||||
|
||||
it('sucht ohne Angabe über alle Arten', async () => {
|
||||
// Der Auslieferungszustand: wer einen Namen eintippt, will wissen, wo er
|
||||
// vorkommt, und nicht zuerst entscheiden, wonach er sucht.
|
||||
expect(parseFilters({}).kind).toBe('all')
|
||||
expect(parseFilters({ kind: 'personnel' }).kind).toBe('personnel')
|
||||
})
|
||||
|
||||
it('zählt die Treffer aller Arten, damit man sie nicht sucht', async () => {
|
||||
// Der Anlass: wer nach einem Namen sucht, landet auf den Fallakten und
|
||||
// sieht nicht, dass es zu demselben Namen auch eine Personalakte gibt.
|
||||
@@ -258,6 +265,7 @@ describe('Filter als Adresse', () => {
|
||||
it('nimmt keine erfundene Stufe und keine erfundene Art an', () => {
|
||||
const gefiltert = parseFilters({ classification: '9', kind: 'unfug' })
|
||||
expect(gefiltert.classification).toBeUndefined()
|
||||
expect(gefiltert.kind).toBe('case')
|
||||
// Unsinn faellt auf den Auslieferungszustand zurueck, und der ist „alle".
|
||||
expect(gefiltert.kind).toBe('all')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user