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
168 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|