Files
nexarch/web/mail-search/app/page.tsx
T
sysopsandClaude Sonnet 5 acc2b0c5dd SRC-06: facetten-ui-filter-chips
Facetten-Oberfläche mit Filter-Chips: aktive Filter sichtbar, einzeln
entfernbar, Trefferzahl je Facettenwert live angezeigt.

- app/api/facets/route.ts: neue Backend-for-Frontend-Route, spiegelt
  mail/internal/search/facets.go minimal (nur Trefferzahl je
  Facettenwert).
- lib/manticoreQuery.ts: gemeinsamer statischer bool.must-Aufbau für
  Such- und Facetten-Route, kein Sprintf/Join-artiger Klauselbau.
- app/api/search/route.ts (SRC-04): akzeptiert jetzt wiederholbare
  ?filter=feld:wert-Parameter.
- app/FacetPanel.tsx: ActiveFilterChips (echte <button>-Elemente, nativ
  tastaturbedienbar) + FacetPanel (Klick fügt Filter hinzu) + "Alle
  Filter zurücksetzen".
- lib/filterState.ts: reine Filterzustandsfunktionen, ohne React.

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-06-PRUEFPROTOKOLL.md):
1. Manueller Test gegen echten next start + live Manticore auf
   192.168.1.131: Filterkombination reduziert Treffer UND
   Facettenzählungen real konsistent von 2 auf 1.
2. @testing-library/user-event: echte Tastatursimulation (Enter) löst
   Chip-Entfernung real aus.
3. 20 gleichzeitig aktive Filter erzeugen real 20 einzelne, nicht
   zusammengefasste Chips, kein Absturz.

Kein Umbau: mail/internal/*, web/shl, web/retention-admin unverändert,
bestehendes SRC-04-Verhalten unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
2026-08-31 23:18:08 +02:00

168 lines
5.1 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, type FormEvent } from "react";
import Link from "next/link";
import { TextField } from "@nexarch/shl";
import { search, facets, ApiError, type SearchHit, type AppliedFilter, type FacetsResponse } from "../lib/api";
import { splitHighlighted } from "../lib/highlight";
import { HIGHLIGHT_BG_LIGHT, HIGHLIGHT_FG_LIGHT } from "../lib/highlightColors";
import { ActiveFilterChips, FacetPanel } from "./FacetPanel";
import { addFilter, removeFilter } from "../lib/filterState";
function tenantSlug(): string {
return process.env.NEXT_PUBLIC_MAIL_TENANT_SLUG ?? "";
}
function Snippet({ text }: { text: string }) {
if (!text) {
return null;
}
return (
<span>
{splitHighlighted(text).map((segment, idx) =>
segment.matched ? (
<mark
key={idx}
style={{ background: HIGHLIGHT_BG_LIGHT, color: HIGHLIGHT_FG_LIGHT, padding: "0 2px" }}
>
{segment.text}
</mark>
) : (
<span key={idx}>{segment.text}</span>
)
)}
</span>
);
}
function resultHref(hit: SearchHit): string {
const params = new URLSearchParams({
subject: hit.subjectSnippet,
snippet: hit.bodySnippet,
});
return `/mail/${encodeURIComponent(hit.messageId)}?${params.toString()}#fundstelle`;
}
export default function SearchPage() {
const [query, setQuery] = useState("");
const [appliedFilters, setAppliedFilters] = useState<AppliedFilter[]>([]);
const [hits, setHits] = useState<SearchHit[] | null>(null);
const [facetsData, setFacetsData] = useState<FacetsResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const runSearch = useCallback(
async (q: string, filters: AppliedFilter[]) => {
if (!q.trim() && filters.length === 0) {
setHits(null);
setFacetsData(null);
setError(null);
return;
}
setLoading(true);
setError(null);
try {
const [searchResult, facetsResult] = await Promise.all([
search(tenantSlug(), q, filters),
facets(tenantSlug(), q, filters),
]);
setHits(searchResult.hits);
setFacetsData(facetsResult);
} catch (err) {
// Akzeptanzkriterium 3 (SRC-04, sinngemäß auf Fehlerfall
// übertragen): auch ein Suchfehler zeigt einen verständlichen
// Hinweis statt einer leeren Fläche.
setHits([]);
setFacetsData(null);
setError(err instanceof ApiError ? err.message : "Suche konnte nicht ausgeführt werden.");
} finally {
setLoading(false);
}
},
[]
);
function onSubmit(e: FormEvent) {
e.preventDefault();
void runSearch(query, appliedFilters);
}
// SRC-06 Akzeptanzkriterium 2: Trefferzahl je Facettenwert live
// aktualisiert — jede Filteränderung löst Such- UND Facettenabfrage
// erneut aus.
useEffect(() => {
void runSearch(query, appliedFilters);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appliedFilters]);
function onAddFilter(filter: AppliedFilter) {
setAppliedFilters((prev) => addFilter(prev, filter));
}
function onRemoveFilter(filter: AppliedFilter) {
setAppliedFilters((prev) => removeFilter(prev, filter));
}
function onClearFilters() {
setAppliedFilters([]);
}
return (
<main style={{ maxWidth: 720, margin: "40px auto", padding: "0 16px" }}>
<h1>Mail-Suche</h1>
<form onSubmit={onSubmit}>
<TextField
label="Suchbegriff"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder='z. B. "Quartalsbericht" oder Umsatz -Verlust'
/>
<button type="submit" disabled={loading} style={{ marginTop: 8 }}>
Suchen
</button>
</form>
<ActiveFilterChips
facetsData={facetsData}
appliedFilters={appliedFilters}
onAddFilter={onAddFilter}
onRemoveFilter={onRemoveFilter}
onClearFilters={onClearFilters}
/>
{error && (
<p role="alert" style={{ marginTop: 16 }}>
{error}
</p>
)}
{hits !== null && hits.length === 0 && !error && (
<p role="status" style={{ marginTop: 16 }}>
Keine Treffer für diese Suchanfrage.
</p>
)}
<FacetPanel
facetsData={facetsData}
appliedFilters={appliedFilters}
onAddFilter={onAddFilter}
onRemoveFilter={onRemoveFilter}
onClearFilters={onClearFilters}
/>
{hits !== null && hits.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, marginTop: 16 }}>
{hits.map((hit) => (
<li key={hit.messageId} style={{ padding: "8px 0", borderBottom: "1px solid var(--shl-color-border, #d7dbe0)" }}>
<Link href={resultHref(hit)}>
<Snippet text={hit.subjectSnippet} />
<div>
<Snippet text={hit.bodySnippet} />
</div>
</Link>
</li>
))}
</ul>
)}
</main>
);
}