diff --git a/TASKS.md b/TASKS.md
index b15aa9e..336e72f 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -116,11 +116,12 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau.
## Phase 7 — Suche
-- [ ] `search_vector` mit Trigger und Gewichtung, GIN-Index
-- [ ] Suchseite mit Filtern, Debounce, AbortController, gedimmte alte Treffer
-- [ ] Sichtbarkeitsprüfung in derselben Abfrage, auch für Facettenzähler
-- [ ] Kommandopalette auf Strg+K
-- [ ] Gespeicherte Ansichten
+- [x] `search_vector` mit Trigger und Gewichtung, GIN-Index — je Freigabestufe einer
+- [x] Suchseite mit Filtern, Debounce, AbortController, gedimmte alte Treffer
+- [x] Sichtbarkeitsprüfung in derselben Abfrage, auch für Facettenzähler
+- [x] Kommandopalette auf Strg+K
+- [x] Gespeicherte Ansichten
+- [ ] „Zuletzt geöffnet" als Ansicht — braucht eine eigene Spur, siehe docs/13
## Phase 8 — Export
diff --git a/docs/08-suche-und-export.md b/docs/08-suche-und-export.md
index 57261f2..f8e9c63 100644
--- a/docs/08-suche-und-export.md
+++ b/docs/08-suche-und-export.md
@@ -4,8 +4,11 @@
Postgres Full-Text-Search, deutsche Konfiguration. Kein Elasticsearch.
-- `search_vector` auf `Case` per Trigger aus Aktenzeichen und Titel (Gewicht A),
- Abschnittswerten (B) und Verlaufseinträgen (C)
+- `SearchIndex` per Trigger aus Aktenzeichen und Titel (Gewicht A), Abschnittswerten (B) und
+ Verlaufseinträgen (C)
+- **Je Freigabestufe ein Vektor.** Die Zeile zur Stufe N enthält alles, was ein Leser dieser
+ Stufe sehen darf; eine Suche nimmt genau diese eine Zeile. Geschwärzte Stellen werden
+ stufenweise aufgelöst. Begründung in `docs/13-entscheidungen.md`
- GIN-Index, `websearch_to_tsquery('german', $1)`
- Zusätzlich Trigram-Index (`pg_trgm`) auf `caseNumber` und `displayName` für Tippfehler
diff --git a/docs/13-entscheidungen.md b/docs/13-entscheidungen.md
index 8502dcb..a67787e 100644
--- a/docs/13-entscheidungen.md
+++ b/docs/13-entscheidungen.md
@@ -648,3 +648,73 @@ geändert da; das Aufräumen war von Hand nötig.
Die Lehre steht hier, weil sie sich wiederholen wird: **vor jedem neuen Modell, Enum oder Feld
im Schema nachsehen, ob es das schon gibt.** Das Schema ist über achthundert Zeilen lang, und
der Teil, den man gerade nicht bearbeitet, ist der, den man vergisst.
+
+## Suche
+
+### Ein Vektor je Freigabestufe
+
+Der Suchvektor aus Phase 2 enthielt nur, was **jeder** sehen darf (`minClearance = 0`). Das
+war leck-sicher und unbrauchbar zugleich: ab Stufe 1 fand die Suche nichts von dem, was einen
+Fall ausmacht. Der Kommentar in der Migration sagte schon, dass Phase 7 getrennte Vektoren je
+Stufe ergänzt — hier sind sie.
+
+`SearchIndex` hält je Ziel sechs Zeilen, eine für jede Stufe. Die Zeile zur Stufe N enthält
+alles, was ein Leser dieser Stufe sehen darf: Kopfdaten, Abschnitte und Einträge bis
+einschliesslich N. Eine Suche nimmt **genau eine Zeile** — die zur eigenen Stufe — und kann
+deshalb nichts finden, was sie nicht lesen darf.
+
+Sechs Zeilen je Fall statt einer ist bei ein paar hundert Akten kein Thema, und die Abfrage
+wird dadurch einfacher, nicht schwerer: kein `DISTINCT`, keine Aggregation über mehrere
+Zeilen, ein einzelner Gleichheitsvergleich auf `minClearance`.
+
+### Schwärzungen werden stufenweise aufgelöst
+
+`strip_redactions` warf jede markierte Stelle weg. Für einen gestuften Index ist das falsch in
+beide Richtungen: ein Leser der Stufe 4 fände einen Namen nicht, den er auf der Seite im
+Klartext sieht, und ohne die Funktion fände ein Leser der Stufe 1 einen, den er dort nur als
+Balken sieht.
+
+`strip_redactions_above(text, stufe)` entfernt deshalb nur, was **über** der Stufe liegt, und
+löst die übrigen Marker auf. Ein Durchgang je Stufe darüber, weil die Stufe als Ziffer im
+Marker steht und `regexp_replace` nicht auf einen Fund hin verzweigen kann.
+
+### Die Suchbedingung ist nicht die Zugriffsprüfung
+
+Die Suche läuft in zwei Schritten, und der Unterschied ist der Punkt:
+
+1. **Was passt** — eine Abfrage auf `SearchIndex`, die höchstens 500 Kennungen nach Rang
+ liefert.
+2. **Was der Suchende sehen darf und wie viele es sind** — eine Abfrage mit
+ `visibleCaseWhere` und den Filtern. Treffer, Trefferzahl **und Facettenzähler** entstehen
+ dort gemeinsam.
+
+Der erste Schritt ist eine Suchbedingung, kein Filter im Sinne von docs/08 — die
+Zugriffsprüfung steht ausschliesslich im zweiten und stammt aus derselben Funktion wie in der
+Fallliste. Es gibt keine zweite Fassung davon, und es sollte auch nie eine geben: eine in SQL
+nachgebaute Sichtbarkeitsregel wäre genau die Sorte Duplikat, die irgendwann auseinanderläuft.
+
+Die Begrenzung auf 500 kann bei sehr allgemeinen Begriffen etwas abschneiden. Die Oberfläche
+sagt das dann ausdrücklich, statt eine vollständige Liste vorzutäuschen.
+
+### Filter stehen in der Adresse, Ansichten sind Lesezeichen
+
+Jeder Zustand der Suchseite ist eine Adresse. Damit ist er teilbar, der Zurück-Knopf tut das
+Richtige, und eine gespeicherte Ansicht ist nichts weiter als eine gemerkte Adresse.
+
+Die **mitgelieferten** Ansichten stehen im Programm und nicht in der Datenbank, weil sie von
+der Gegenwart abhängen: „Wiedervorlage diese Woche" und „unbearbeitet seit 14 Tagen" wären als
+gespeicherte Zeitpunkte schon am nächsten Tag falsch.
+
+„Zuletzt geöffnet" aus docs/08 fehlt: dafür müsste jeder Aufruf jeder Akte mitgeschrieben
+werden, und das Zugriffsprotokoll führt nur Stufe 3 aufwärts. Eine eigene Spur dafür anzulegen
+wäre eine Überwachungsfunktion, die niemand bestellt hat — wenn sie gebraucht wird, sollte das
+eine bewusste Entscheidung sein und keine Nebenwirkung.
+
+### Die Kommandopalette wiegt nichts
+
+Strg+K steckt in jeder Seite, und die Fallakte hat kein Bündel übrig. Deshalb ist der Teil im
+Grundgerüst nur ein Tastaturereignis; die Palette selbst wird geholt, wenn jemand sie öffnet.
+Das kostet die anderen Seiten rund hundert Byte.
+
+Sie sucht bewusst schmal — Aktenzeichen, Titel, Namen, kein Volltext über Inhalte. Wer sie
+öffnet, will springen, nicht recherchieren.
diff --git a/packages/db/prisma/migrations/20260903140000_suche/migration.sql b/packages/db/prisma/migrations/20260903140000_suche/migration.sql
new file mode 100644
index 0000000..7a5f80b
--- /dev/null
+++ b/packages/db/prisma/migrations/20260903140000_suche/migration.sql
@@ -0,0 +1,237 @@
+-- Phase 7: Suche.
+--
+-- Der bisherige Suchvektor enthielt nur, was jeder sehen darf (minClearance
+-- = 0). Das war leck-sicher, aber unbrauchbar: ab Stufe 1 fand die Suche
+-- nichts von dem, was den Fall ausmacht.
+--
+-- Stattdessen jetzt ein Vektor **je Freigabestufe**. Der zur Stufe N enthält
+-- alles bis einschliesslich N. Eine Suche nimmt genau die Zeile zur Stufe des
+-- Suchenden und kann deshalb nichts finden, was er nicht lesen darf.
+
+CREATE TABLE "SearchIndex" (
+ "targetType" TEXT NOT NULL,
+ "targetId" TEXT NOT NULL,
+ "minClearance" INTEGER NOT NULL,
+ "vector" tsvector NOT NULL,
+
+ CONSTRAINT "SearchIndex_pkey" PRIMARY KEY ("targetType", "targetId", "minClearance")
+);
+
+CREATE INDEX "SearchIndex_vector_idx" ON "SearchIndex" USING GIN ("vector");
+CREATE INDEX "SearchIndex_targetType_minClearance_idx"
+ ON "SearchIndex"("targetType", "minClearance");
+
+CREATE TABLE "SavedView" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "query" TEXT NOT NULL,
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "SavedView_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "SavedView_userId_sortOrder_idx" ON "SavedView"("userId", "sortOrder");
+
+ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_userId_fkey"
+ FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- ===========================================================================
+-- Schwärzungen stufenweise auflösen
+--
+-- `strip_redactions` entfernt jede markierte Stelle. Für den Vektor einer
+-- Stufe muss aber stehen bleiben, was diese Stufe lesen darf — sonst findet
+-- ein Leser der Stufe 4 einen Namen nicht, den er auf der Seite sieht.
+-- ===========================================================================
+
+CREATE OR REPLACE FUNCTION strip_redactions_above(input text, max_level int)
+RETURNS text AS $$
+DECLARE
+ ergebnis text := coalesce(input, '');
+ stufe int;
+BEGIN
+ -- Erst alles wegwerfen, was über der Stufe liegt. Die Stufe steht als eine
+ -- Ziffer im Marker, deshalb je Stufe ein Durchgang.
+ FOR stufe IN (max_level + 1)..5 LOOP
+ ergebnis := regexp_replace(
+ ergebnis,
+ '\[\[c' || stufe || '\]\].*?\[\[/c' || stufe || '\]\]',
+ ' ',
+ 'g'
+ );
+ END LOOP;
+
+ -- Was übrig ist, darf gelesen werden: nur die Marker selbst entfernen.
+ RETURN regexp_replace(ergebnis, '\[\[/?c[0-9]\]\]', ' ', 'g');
+END;
+$$ LANGUAGE plpgsql IMMUTABLE;
+
+-- ===========================================================================
+-- Fallakte: sechs Vektoren
+--
+-- Gewichtung wie in docs/08: A = Aktenzeichen und Titel, B = Abschnitte,
+-- C = Verlaufseinträge.
+-- ===========================================================================
+
+CREATE OR REPLACE FUNCTION refresh_case_search(target_id text) RETURNS void AS $$
+DECLARE
+ stufe int;
+ kopf tsvector;
+ abschnitte tsvector;
+ eintraege tsvector;
+BEGIN
+ DELETE FROM "SearchIndex" WHERE "targetType" = 'case' AND "targetId" = target_id;
+
+ SELECT setweight(to_tsvector('german', c."caseNumber" || ' ' || c."title"), 'A')
+ INTO kopf
+ FROM "Case" c WHERE c."id" = target_id;
+
+ IF kopf IS NULL THEN
+ RETURN; -- Der Fall ist weg, dann gehört auch kein Index dazu.
+ END IF;
+
+ FOR stufe IN 0..5 LOOP
+ SELECT coalesce(
+ to_tsvector('german', string_agg(strip_redactions_above(s."value" #>> '{}', stufe), ' ')),
+ ''::tsvector
+ ) INTO abschnitte
+ FROM "CaseSection" s
+ WHERE s."caseId" = target_id AND s."minClearance" <= stufe;
+
+ SELECT coalesce(
+ to_tsvector('german', string_agg(strip_redactions_above(e."body", stufe), ' ')),
+ ''::tsvector
+ ) INTO eintraege
+ FROM "CaseEntry" e
+ WHERE e."caseId" = target_id
+ AND e."minClearance" <= stufe
+ AND e."retractedAt" IS NULL;
+
+ INSERT INTO "SearchIndex" ("targetType", "targetId", "minClearance", "vector")
+ VALUES (
+ 'case',
+ target_id,
+ stufe,
+ kopf || setweight(abschnitte, 'B') || setweight(eintraege, 'C')
+ );
+ END LOOP;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION refresh_document_search(target_id text) RETURNS void AS $$
+DECLARE
+ stufe int;
+ kopf tsvector;
+ abschnitte tsvector;
+BEGIN
+ DELETE FROM "SearchIndex" WHERE "targetType" = 'document' AND "targetId" = target_id;
+
+ SELECT setweight(to_tsvector('german', d."documentNumber" || ' ' || d."title"), 'A')
+ INTO kopf
+ FROM "Document" d WHERE d."id" = target_id;
+
+ IF kopf IS NULL THEN
+ RETURN;
+ END IF;
+
+ FOR stufe IN 0..5 LOOP
+ SELECT coalesce(
+ to_tsvector('german', string_agg(strip_redactions_above(s."value" #>> '{}', stufe), ' ')),
+ ''::tsvector
+ ) INTO abschnitte
+ FROM "DocumentSection" s
+ WHERE s."documentId" = target_id AND s."minClearance" <= stufe;
+
+ INSERT INTO "SearchIndex" ("targetType", "targetId", "minClearance", "vector")
+ VALUES ('document', target_id, stufe, kopf || setweight(abschnitte, 'B'));
+ END LOOP;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Die Personalakte hat keine gestuften Inhalte; ihre Kopfdaten stehen für
+-- jede Stufe gleich. Die Zeile wird trotzdem sechsmal geschrieben, damit die
+-- Abfrage über alle drei Arten dieselbe bleibt.
+CREATE OR REPLACE FUNCTION refresh_personnel_search(target_id text) RETURNS void AS $$
+DECLARE
+ stufe int;
+ vec tsvector;
+BEGIN
+ DELETE FROM "SearchIndex" WHERE "targetType" = 'personnel' AND "targetId" = target_id;
+
+ SELECT setweight(to_tsvector('german', p."displayName"), 'A')
+ || setweight(to_tsvector('german',
+ coalesce(p."rank", '') || ' ' || coalesce(p."department", '') || ' ' ||
+ coalesce(array_to_string(p."statusTags", ' '), '')), 'B')
+ || setweight(to_tsvector('german', coalesce(p."note", '')), 'C')
+ INTO vec
+ FROM "PersonnelFile" p WHERE p."id" = target_id;
+
+ IF vec IS NULL THEN
+ RETURN;
+ END IF;
+
+ FOR stufe IN 0..5 LOOP
+ INSERT INTO "SearchIndex" ("targetType", "targetId", "minClearance", "vector")
+ VALUES ('personnel', target_id, stufe, vec);
+ END LOOP;
+END;
+$$ LANGUAGE plpgsql;
+
+-- ===========================================================================
+-- Auslöser
+--
+-- Wie beim alten Vektor: auch bei UPDATE, sonst findet die Suche nach einer
+-- nachträglichen Änderung den alten Text.
+-- ===========================================================================
+
+CREATE OR REPLACE FUNCTION search_index_trigger() RETURNS trigger AS $$
+DECLARE
+ ziel text;
+BEGIN
+ IF TG_TABLE_NAME = 'Case' THEN
+ PERFORM refresh_case_search(NEW."id");
+ ELSIF TG_TABLE_NAME IN ('CaseSection', 'CaseEntry') THEN
+ ziel := CASE WHEN TG_OP = 'DELETE' THEN OLD."caseId" ELSE NEW."caseId" END;
+ IF ziel IS NOT NULL THEN PERFORM refresh_case_search(ziel); END IF;
+ ELSIF TG_TABLE_NAME = 'Document' THEN
+ PERFORM refresh_document_search(NEW."id");
+ ELSIF TG_TABLE_NAME = 'DocumentSection' THEN
+ ziel := CASE WHEN TG_OP = 'DELETE' THEN OLD."documentId" ELSE NEW."documentId" END;
+ IF ziel IS NOT NULL THEN PERFORM refresh_document_search(ziel); END IF;
+ ELSIF TG_TABLE_NAME = 'PersonnelFile' THEN
+ PERFORM refresh_personnel_search(NEW."id");
+ END IF;
+ RETURN NULL;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER search_on_case
+ AFTER INSERT OR UPDATE OF "title", "caseNumber" ON "Case"
+ FOR EACH ROW EXECUTE FUNCTION search_index_trigger();
+
+CREATE TRIGGER search_on_case_section
+ AFTER INSERT OR UPDATE OR DELETE ON "CaseSection"
+ FOR EACH ROW EXECUTE FUNCTION search_index_trigger();
+
+CREATE TRIGGER search_on_case_entry
+ AFTER INSERT OR UPDATE ON "CaseEntry"
+ FOR EACH ROW EXECUTE FUNCTION search_index_trigger();
+
+CREATE TRIGGER search_on_document
+ AFTER INSERT OR UPDATE OF "title", "documentNumber" ON "Document"
+ FOR EACH ROW EXECUTE FUNCTION search_index_trigger();
+
+CREATE TRIGGER search_on_document_section
+ AFTER INSERT OR UPDATE OR DELETE ON "DocumentSection"
+ FOR EACH ROW EXECUTE FUNCTION search_index_trigger();
+
+CREATE TRIGGER search_on_personnel
+ AFTER INSERT OR UPDATE OF "displayName", "rank", "department", "statusTags", "note"
+ ON "PersonnelFile"
+ FOR EACH ROW EXECUTE FUNCTION search_index_trigger();
+
+-- Bestand einmal aufbauen.
+SELECT refresh_case_search(c."id") FROM "Case" c;
+SELECT refresh_document_search(d."id") FROM "Document" d;
+SELECT refresh_personnel_search(p."id") FROM "PersonnelFile" p;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index a611af7..9ed0051 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -51,6 +51,7 @@ model User {
personnelFiles PersonnelFile[]
participations CaseParticipant[]
assignments PersonnelAssignment[]
+ savedViews SavedView[]
exclusions CaseExclusion[]
watches Watch[]
readMarkers ReadMarker[]
@@ -807,6 +808,43 @@ model NotificationOutbox {
@@index([caseId])
}
+/// Suchindex, **je Freigabestufe ein Vektor**.
+///
+/// Der Vektor zur Stufe N enthaelt alles, was ein Leser dieser Stufe sehen
+/// darf: Kopfdaten, Abschnitte und Eintraege bis einschliesslich N, und aus
+/// dem Fliesstext die geschwaerzten Stellen bis N. Eine Suche nimmt genau
+/// eine Zeile — die zur Stufe des Suchenden — und kann deshalb nichts finden,
+/// was er nicht lesen darf.
+///
+/// Der Vorgaenger war ein einziger Vektor mit nur dem, was jeder sehen darf.
+/// Der war leck-sicher, aber unbrauchbar: ab Stufe 1 fand die Suche nichts von
+/// dem, was den Fall ausmacht. Siehe docs/13-entscheidungen.md.
+model SearchIndex {
+ targetType String // "case" | "document" | "personnel"
+ targetId String
+ minClearance Int // 0..5
+ vector Unsupported("tsvector")
+
+ @@id([targetType, targetId, minClearance])
+ @@index([targetType, minClearance])
+}
+
+/// Eine gespeicherte Suche. Die mitgelieferten Ansichten stehen im Programm,
+/// hier stehen nur die selbst angelegten.
+model SavedView {
+ id String @id @default(uuid(7))
+ userId String
+ name String
+ /// Die Filter als Abfrageparameter, so wie sie in der Adresse stehen.
+ query String
+ sortOrder Int @default(0)
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId, sortOrder])
+}
+
/// Lebenszeichen der Dienste, die nicht im Web-Container laufen. Der Worker
/// schreibt hier seinen Gateway-Zustand hinein, damit /api/health ihn pruefen
/// kann, ohne Discord zu befragen. Siehe docs/11-betrieb.md.
diff --git a/packages/db/tests/regeln.test.ts b/packages/db/tests/regeln.test.ts
index a8e3d03..9dc8ccc 100644
--- a/packages/db/tests/regeln.test.ts
+++ b/packages/db/tests/regeln.test.ts
@@ -214,6 +214,43 @@ suite('Datenbankregeln', () => {
).rejects.toThrow()
})
+ it('löst Schwärzungen im Suchindex stufenweise auf', async () => {
+ // Der Vektor zur Stufe N muss enthalten, was diese Stufe lesen darf — und
+ // nichts darüber. Sonst findet ein Leser der Stufe 4 einen Namen nicht,
+ // den er auf der Seite sieht, oder ein Leser der Stufe 1 einen, den er
+ // dort nur als Balken sieht.
+ const [zeile] = await db.$queryRawUnsafe<{ tief: string; hoch: string }[]>(
+ `SELECT strip_redactions_above($1, 1) AS tief,
+ strip_redactions_above($1, 4) AS hoch`,
+ 'Offen [[c4]]Perreira[[/c4]] Schluss',
+ )
+
+ expect(zeile?.tief).not.toContain('Perreira')
+ expect(zeile?.tief).toContain('Offen')
+ expect(zeile?.hoch).toContain('Perreira')
+ // Die Marker selbst gehören nie in den Index.
+ expect(zeile?.hoch).not.toContain('[[c4]]')
+ })
+
+ it('schreibt für jede Stufe eine Indexzeile und zieht sie nach', async () => {
+ const stufen = await db.$queryRawUnsafe<{ minClearance: number }[]>(
+ `SELECT "minClearance" FROM "SearchIndex"
+ WHERE "targetType" = 'case' AND "targetId" = $1 ORDER BY "minClearance"`,
+ caseId,
+ )
+ expect(stufen.map((s) => s.minClearance)).toEqual([0, 1, 2, 3, 4, 5])
+
+ // Eine Änderung am Titel muss durchschlagen.
+ await db.case.update({ where: { id: caseId }, data: { title: 'Umbenannter Prüffall' } })
+ const [treffer] = await db.$queryRawUnsafe<{ gefunden: boolean }[]>(
+ `SELECT "vector" @@ websearch_to_tsquery('german', 'Umbenannter') AS gefunden
+ FROM "SearchIndex"
+ WHERE "targetType" = 'case' AND "targetId" = $1 AND "minClearance" = 0`,
+ caseId,
+ )
+ expect(treffer?.gefunden).toBe(true)
+ })
+
it('nimmt geschwärzte Bereiche nicht in den Suchvektor auf', async () => {
await db.caseEntry.create({
data: {
diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx
index 5142511..1896cfd 100644
--- a/web/app/(app)/layout.tsx
+++ b/web/app/(app)/layout.tsx
@@ -2,6 +2,7 @@ import Link from 'next/link'
import { signOut } from '@/auth'
import { requireUser } from '@/lib/auth/current'
import { de } from '@/lib/i18n/de'
+import { PaletteTrigger } from '@/components/palette/PaletteTrigger'
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser()
@@ -13,6 +14,8 @@ export default async function AppLayout({ children }: { children: React.ReactNod
return (
+ {/* Strg+K. Winzig; die Palette selbst wird erst beim Öffnen geholt. */}
+
@@ -25,6 +28,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
{user.permissions.isAdmin || user.permissions.permissions.has('personnel.read') ? (
{de.nav.personnel}
) : null}
+ {de.nav.search}
{de.channel.heading}
{de.notify.heading}
{user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? (
diff --git a/web/app/(app)/suche/SavedViews.tsx b/web/app/(app)/suche/SavedViews.tsx
new file mode 100644
index 0000000..7bc3136
--- /dev/null
+++ b/web/app/(app)/suche/SavedViews.tsx
@@ -0,0 +1,106 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import Link from 'next/link'
+import { searchText } from '@/lib/i18n/shell'
+import { commonText } from '@/lib/i18n/common'
+import { deleteViewAction, saveViewAction } from './actions'
+
+/**
+ * Ansichten: die mitgelieferten und die eigenen.
+ *
+ * Beide sind dasselbe — eine Suchadresse. Die mitgelieferten stehen im
+ * Programm, weil sie von der Gegenwart abhängen („diese Woche"), die eigenen
+ * in der Datenbank.
+ */
+export function SavedViews({
+ builtins,
+ saved,
+ currentQuery,
+}: {
+ builtins: { key: string; params: string }[]
+ saved: { id: string; name: string; query: string }[]
+ currentQuery: string
+}) {
+ const [name, setName] = useState('')
+ const [offen, setOffen] = useState(false)
+ const [fehler, setFehler] = useState(null)
+ const [pending, startTransition] = useTransition()
+
+ return (
+