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
101 lines
3.4 KiB
TypeScript
101 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import type { CSSProperties } from "react";
|
|
import { FACET_FIELDS, FACET_FIELD_LABELS } from "../lib/facetFields";
|
|
import type { AppliedFilter, FacetsResponse } from "../lib/api";
|
|
import { isFilterActive } from "../lib/filterState";
|
|
|
|
interface FacetPanelProps {
|
|
facetsData: FacetsResponse | null;
|
|
appliedFilters: AppliedFilter[];
|
|
onAddFilter: (filter: AppliedFilter) => void;
|
|
onRemoveFilter: (filter: AppliedFilter) => void;
|
|
onClearFilters: () => void;
|
|
}
|
|
|
|
const chipStyle: CSSProperties = {
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
padding: "4px 10px",
|
|
borderRadius: 999,
|
|
border: "1px solid var(--shl-color-border, #d7dbe0)",
|
|
background: "var(--shl-color-surface, #f5f6f8)",
|
|
marginRight: 8,
|
|
marginBottom: 8,
|
|
cursor: "pointer",
|
|
font: "inherit",
|
|
};
|
|
|
|
// SRC-06 Akzeptanzkriterium 1: aktive Filter erscheinen als entfernbare
|
|
// Chips oberhalb der Trefferliste. Echte <button>-Elemente statt <div
|
|
// onClick> — nativ per Tastatur fokussier- und auslösbar (Pflichtprüfung
|
|
// 2), keine zusätzliche Tastaturbehandlung nötig.
|
|
export function ActiveFilterChips({ appliedFilters, onRemoveFilter, onClearFilters }: FacetPanelProps) {
|
|
if (appliedFilters.length === 0) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div style={{ marginTop: 16 }} aria-label="Aktive Filter">
|
|
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center" }}>
|
|
{appliedFilters.map((f) => (
|
|
<button
|
|
key={`${f.field}:${f.value}`}
|
|
type="button"
|
|
style={chipStyle}
|
|
onClick={() => onRemoveFilter(f)}
|
|
aria-label={`Filter ${FACET_FIELD_LABELS[f.field]}: ${f.value} entfernen`}
|
|
>
|
|
{FACET_FIELD_LABELS[f.field]}: {f.value} ✕
|
|
</button>
|
|
))}
|
|
</div>
|
|
{/* Akzeptanzkriterium 3: Zurücksetzen aller Filter mit einem Klick. */}
|
|
<button type="button" onClick={onClearFilters}>
|
|
Alle Filter zurücksetzen
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// SRC-06 Akzeptanzkriterium 2: Trefferzahl je Facettenwert live
|
|
// aktualisiert (facetsData kommt bei jeder Such-/Filteränderung neu vom
|
|
// Server).
|
|
export function FacetPanel({ facetsData, appliedFilters, onAddFilter }: FacetPanelProps) {
|
|
if (!facetsData) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div style={{ marginTop: 16 }}>
|
|
{FACET_FIELDS.map((field) => {
|
|
const values = facetsData.values[field] ?? [];
|
|
if (values.length === 0) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div key={field} style={{ marginBottom: 12 }}>
|
|
<strong>{FACET_FIELD_LABELS[field]}</strong>
|
|
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
|
|
{values.map((v) => {
|
|
const active = isFilterActive(appliedFilters, field, v.value);
|
|
return (
|
|
<li key={v.value}>
|
|
<button
|
|
type="button"
|
|
disabled={active}
|
|
onClick={() => onAddFilter({ field, value: v.value })}
|
|
style={{ background: "none", border: "none", padding: "2px 0", cursor: active ? "default" : "pointer", opacity: active ? 0.5 : 1 }}
|
|
>
|
|
{v.value} ({v.count})
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|