Workstream 5: Dynamische Suche & Filter (Phase 3)
Implementiert die Startseite mit Tabs (Fahrzeuge/Geräte/Wehren), Namens-/
Funkrufnamen-Suche und ein dynamisch aus dem aktiven Merkmal-Katalog erzeugtes
Filter-UI (Slider/Multi-Select/Tri-State Switch) plus Status-Filter.
Kern:
- src/lib/search/types.ts: uuid-IDs durchgängig (SearchHit, FacetDef, FilterValue).
- src/lib/search/parse-params.ts: typisiertes Parsen von f.<uuid>=… (number lo..hi,
enum CSV, boolean ja/nein) + q + bereit; Ungültiges wird still verworfen.
- src/lib/search/facets.ts: lädt nur status='active', geltungsbereich in (typ,'both'),
typ<>'text'; min/max je number, Optionen sortiert je enum.
- src/lib/search/query-vehicles.ts: Name+Funkrufname (OR) + Status + UND-verknüpfte
EXISTS-Filter je merkmal_id; Allrad-Regel via expandNameQuery; keine Sortierung.
- src/lib/search/query-equipment.ts: wie Fahrzeuge, ohne Allrad, mit categoryId.
- src/lib/search/query-brigades.ts: Name/Ort/PLZ, nur aktive Wehren.
- src/lib/admin/codes.ts: gemeinsame Allrad-Namensregel (HLFA->HLF, Allrad impliziert);
Eigentum Admin-WS, hier rein/testbar bereitgestellt und importiert.
- src/lib/db/indexes-trgm.sql: nur pg_trgm-GIN-Indizes auf vehicles.name/funkrufname
(idempotent); merkmal_values-Indizes bleiben Eigentum des DB-WS.
UI:
- src/components/search: SearchTabs, SearchBar (debounced q), FilterPanel (dispatch +
Status-Switch), useSearchParams (router.replace ohne Reload, atomares setParams),
StandortBar; facets/{NumberRange,Enum,Boolean}; results/{ResultList,Vehicle,Equipment,
Brigade}Row mit Empty-State und offenem ETA-Slot.
- src/app/(app)/{page,fahrzeuge,geraete,wehren}: Server Components mit requireSession()
als erster Zeile (default-deny in der Tiefe zusätzlich zum Layout-Gate). /fahrzeuge
sortiert bei gesetztem Standort via searchHitsToGeoCandidates + orderByEintreffzeit.
Tests:
- Units (ohne DB): codes, parse-params, query-vehicles (SQL-Render via PgDialect).
- tests/e2e/search.spec.ts geschrieben (deferred — kein Server/DB in Sandbox).
Verifiziert offline: tsc --noEmit (0 Fehler), eslint (0), drizzle-kit check (ok),
vitest src/lib (57 grün), next build (Compiled successfully, Routen registriert).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
76
src/app/(app)/fahrzeuge/page.tsx
Normal file
76
src/app/(app)/fahrzeuge/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { requireSession } from "@/lib/auth/guards";
|
||||||
|
import { getFacets } from "@/lib/search/facets";
|
||||||
|
import { parseSearchParams } from "@/lib/search/parse-params";
|
||||||
|
import { searchVehicles } from "@/lib/search/query-vehicles";
|
||||||
|
import { searchHitsToGeoCandidates } from "@/lib/geo/candidates";
|
||||||
|
import { orderByEintreffzeit } from "@/lib/geo/eintreffzeit";
|
||||||
|
import type { Coordinates } from "@/lib/geo/types";
|
||||||
|
import { SearchTabs } from "@/components/search/SearchTabs";
|
||||||
|
import { SearchBar } from "@/components/search/SearchBar";
|
||||||
|
import { StandortBar } from "@/components/search/StandortBar";
|
||||||
|
import { FilterPanel } from "@/components/search/FilterPanel";
|
||||||
|
import { ResultList } from "@/components/search/results/ResultList";
|
||||||
|
import { VehicleResultRow, type VehicleRowData } from "@/components/search/results/VehicleResultRow";
|
||||||
|
import { de } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
type SearchParamsRecord = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
/** Liest lat/lng aus der URL; liefert null, wenn unvollständig/ungültig. */
|
||||||
|
function readOrigin(sp: SearchParamsRecord): Coordinates | null {
|
||||||
|
const lat = Number(Array.isArray(sp.lat) ? sp.lat[0] : sp.lat);
|
||||||
|
const lng = Number(Array.isArray(sp.lng) ? sp.lng[0] : sp.lng);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||||
|
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null;
|
||||||
|
return { lat, lng };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function FahrzeugePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParamsRecord>;
|
||||||
|
}) {
|
||||||
|
// Default-deny in der Tiefe (Querschnittsstandard 1): Guard als erste Zeile.
|
||||||
|
await requireSession();
|
||||||
|
|
||||||
|
const sp = await searchParams;
|
||||||
|
const facets = await getFacets("vehicle");
|
||||||
|
const params = parseSearchParams(sp, facets);
|
||||||
|
const hits = await searchVehicles(params);
|
||||||
|
|
||||||
|
// ETA-Sortierung NUR wenn ein Standort gesetzt ist; sonst ungeordnet (offener
|
||||||
|
// ETA-Slot pro Zeile).
|
||||||
|
const origin = readOrigin(sp);
|
||||||
|
let rows: VehicleRowData[];
|
||||||
|
if (origin) {
|
||||||
|
const candidates = await searchHitsToGeoCandidates(origin, hits);
|
||||||
|
const ordered = await orderByEintreffzeit(origin, candidates);
|
||||||
|
rows = ordered.map((o) => ({
|
||||||
|
entityTyp: o.entityTyp,
|
||||||
|
entityId: o.entityId,
|
||||||
|
brigadeId: o.brigadeId,
|
||||||
|
name: o.name,
|
||||||
|
funkrufname: o.funkrufname,
|
||||||
|
status: o.status,
|
||||||
|
eta: o.eta,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
rows = hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="text-xl font-semibold text-navy">{de.nav.fahrzeuge}</h1>
|
||||||
|
<SearchTabs />
|
||||||
|
<SearchBar />
|
||||||
|
<StandortBar />
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-[16rem_1fr]">
|
||||||
|
<FilterPanel facets={facets} />
|
||||||
|
<ResultList count={rows.length}>
|
||||||
|
{rows.map((hit) => (
|
||||||
|
<VehicleResultRow key={hit.entityId} hit={hit} />
|
||||||
|
))}
|
||||||
|
</ResultList>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/app/(app)/geraete/page.tsx
Normal file
52
src/app/(app)/geraete/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { requireSession } from "@/lib/auth/guards";
|
||||||
|
import { getFacets } from "@/lib/search/facets";
|
||||||
|
import { parseSearchParams } from "@/lib/search/parse-params";
|
||||||
|
import { searchEquipment } from "@/lib/search/query-equipment";
|
||||||
|
import { SearchTabs } from "@/components/search/SearchTabs";
|
||||||
|
import { SearchBar } from "@/components/search/SearchBar";
|
||||||
|
import { FilterPanel } from "@/components/search/FilterPanel";
|
||||||
|
import { ResultList } from "@/components/search/results/ResultList";
|
||||||
|
import { EquipmentResultRow } from "@/components/search/results/EquipmentResultRow";
|
||||||
|
import { de } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
type SearchParamsRecord = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
export default async function GeraetePage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParamsRecord>;
|
||||||
|
}) {
|
||||||
|
// Default-deny in der Tiefe (Querschnittsstandard 1).
|
||||||
|
await requireSession();
|
||||||
|
|
||||||
|
const sp = await searchParams;
|
||||||
|
const facets = await getFacets("equipment");
|
||||||
|
const params = parseSearchParams(sp, facets);
|
||||||
|
const categoryRaw = Array.isArray(sp.kategorie) ? sp.kategorie[0] : sp.kategorie;
|
||||||
|
const hits = await searchEquipment({
|
||||||
|
...params,
|
||||||
|
categoryId:
|
||||||
|
categoryRaw &&
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
|
categoryRaw,
|
||||||
|
)
|
||||||
|
? categoryRaw
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="text-xl font-semibold text-navy">{de.nav.geraete}</h1>
|
||||||
|
<SearchTabs />
|
||||||
|
<SearchBar />
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-[16rem_1fr]">
|
||||||
|
<FilterPanel facets={facets} />
|
||||||
|
<ResultList count={hits.length}>
|
||||||
|
{hits.map((hit) => (
|
||||||
|
<EquipmentResultRow key={hit.entityId} hit={hit} />
|
||||||
|
))}
|
||||||
|
</ResultList>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/app/(app)/page.tsx
Normal file
11
src/app/(app)/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requireSession } from "@/lib/auth/guards";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Startseite der App-Gruppe. Leitet auf den ersten Such-Tab (Fahrzeuge) um.
|
||||||
|
* Default-deny in der Tiefe (Querschnittsstandard 1): Guard als erste Zeile.
|
||||||
|
*/
|
||||||
|
export default async function AppIndexPage() {
|
||||||
|
await requireSession();
|
||||||
|
redirect("/fahrzeuge");
|
||||||
|
}
|
||||||
36
src/app/(app)/wehren/page.tsx
Normal file
36
src/app/(app)/wehren/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { requireSession } from "@/lib/auth/guards";
|
||||||
|
import { searchBrigades } from "@/lib/search/query-brigades";
|
||||||
|
import { SearchTabs } from "@/components/search/SearchTabs";
|
||||||
|
import { SearchBar } from "@/components/search/SearchBar";
|
||||||
|
import { ResultList } from "@/components/search/results/ResultList";
|
||||||
|
import { BrigadeResultRow } from "@/components/search/results/BrigadeResultRow";
|
||||||
|
import { de } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
type SearchParamsRecord = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
export default async function WehrenPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<SearchParamsRecord>;
|
||||||
|
}) {
|
||||||
|
// Default-deny in der Tiefe (Querschnittsstandard 1).
|
||||||
|
await requireSession();
|
||||||
|
|
||||||
|
const sp = await searchParams;
|
||||||
|
const qRaw = Array.isArray(sp.q) ? sp.q[0] : sp.q;
|
||||||
|
const q = qRaw?.trim() || undefined;
|
||||||
|
const hits = await searchBrigades(q);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h1 className="text-xl font-semibold text-navy">{de.nav.wehren}</h1>
|
||||||
|
<SearchTabs />
|
||||||
|
<SearchBar placeholderKey="search.nameOrtPlz" />
|
||||||
|
<ResultList count={hits.length}>
|
||||||
|
{hits.map((hit) => (
|
||||||
|
<BrigadeResultRow key={hit.brigadeId} hit={hit} />
|
||||||
|
))}
|
||||||
|
</ResultList>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/search/FilterPanel.tsx
Normal file
74
src/components/search/FilterPanel.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
import type { FacetDef } from "@/lib/search/types";
|
||||||
|
import { useSearchUrl } from "./useSearchParams";
|
||||||
|
import { NumberRangeFacet } from "./facets/NumberRangeFacet";
|
||||||
|
import { EnumFacet } from "./facets/EnumFacet";
|
||||||
|
import { BooleanFacet } from "./facets/BooleanFacet";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamisches Filter-Panel. Rendert je `active`-Merkmal GENAU EIN UI-Element des
|
||||||
|
* passenden Typs (number->Slider, enum->Multi-Select, boolean->Tri-State) und
|
||||||
|
* schreibt Änderungen über `useSearchUrl` in die URL. Plus Status-Switch
|
||||||
|
* (`bereit=1`). Reine Client-Komponente; die Facetten kommen serverseitig.
|
||||||
|
*/
|
||||||
|
export function FilterPanel({ facets }: { facets: FacetDef[] }) {
|
||||||
|
const { params, setParam, resetFilters } = useSearchUrl();
|
||||||
|
const nurEinsatzbereit = params.get("bereit") === "1";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex flex-col gap-4" aria-label={t("search.filter")}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-anthrazit">
|
||||||
|
{t("search.filter")}
|
||||||
|
</h2>
|
||||||
|
<Button variant="ghost" size="sm" type="button" onClick={resetFilters}>
|
||||||
|
{t("search.filterZuruecksetzen")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="nur-einsatzbereit" className="text-sm text-anthrazit">
|
||||||
|
{t("search.nurEinsatzbereit")}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="nur-einsatzbereit"
|
||||||
|
checked={nurEinsatzbereit}
|
||||||
|
onCheckedChange={(on) => setParam("bereit", on ? "1" : null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{facets.length === 0 ? (
|
||||||
|
<p className="text-sm text-anthrazit/60">{t("search.keineFilter")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{facets.map((facet) => (
|
||||||
|
<FacetField key={facet.merkmalId} facet={facet} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FacetField({ facet }: { facet: FacetDef }) {
|
||||||
|
const { params, setParam } = useSearchUrl();
|
||||||
|
const key = `f.${facet.merkmalId}`;
|
||||||
|
const value = params.get(key) ?? undefined;
|
||||||
|
const onChange = (v: string | null) => setParam(key, v);
|
||||||
|
|
||||||
|
switch (facet.typ) {
|
||||||
|
case "number":
|
||||||
|
return <NumberRangeFacet facet={facet} value={value} onChange={onChange} />;
|
||||||
|
case "enum":
|
||||||
|
return <EnumFacet facet={facet} value={value} onChange={onChange} />;
|
||||||
|
case "boolean":
|
||||||
|
return <BooleanFacet facet={facet} value={value} onChange={onChange} />;
|
||||||
|
default:
|
||||||
|
return null; // text wird nicht als Filter angeboten
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/components/search/SearchBar.tsx
Normal file
48
src/components/search/SearchBar.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
import { useSearchUrl } from "./useSearchParams";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freitext-Suchleiste. Schreibt `q` debounced in die URL (kein Reload). Der
|
||||||
|
* lokale State spiegelt den Eingabewert für sofortiges Tipp-Feedback; die URL
|
||||||
|
* bleibt die Quelle der Wahrheit für das serverseitige Ergebnis.
|
||||||
|
*/
|
||||||
|
export function SearchBar({
|
||||||
|
placeholderKey = "search.suchbegriffPlaceholder",
|
||||||
|
}: {
|
||||||
|
placeholderKey?: "search.suchbegriffPlaceholder" | "search.nameOrtPlz";
|
||||||
|
}) {
|
||||||
|
const { params, setParamDebounced } = useSearchUrl();
|
||||||
|
const urlQ = params.get("q") ?? "";
|
||||||
|
const [value, setValue] = React.useState(urlQ);
|
||||||
|
|
||||||
|
// Bei externer URL-Änderung (z. B. Tab-Wechsel/Reset) den lokalen State
|
||||||
|
// angleichen. Eigene debounced Tipp-Eingaben setzen `urlQ` ohnehin auf `value`,
|
||||||
|
// sodass dieser Effekt dann ein No-op ist.
|
||||||
|
React.useEffect(() => {
|
||||||
|
setValue(urlQ);
|
||||||
|
}, [urlQ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label htmlFor="suchbegriff" className="sr-only">
|
||||||
|
{t("search.suchbegriff")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="suchbegriff"
|
||||||
|
type="search"
|
||||||
|
value={value}
|
||||||
|
placeholder={t(placeholderKey)}
|
||||||
|
aria-label={t("search.suchbegriff")}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value);
|
||||||
|
setParamDebounced("q", e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/search/SearchTabs.tsx
Normal file
34
src/components/search/SearchTabs.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ href: "/fahrzeuge", labelKey: "search.tabFahrzeuge" as const },
|
||||||
|
{ href: "/geraete", labelKey: "search.tabGeraete" as const },
|
||||||
|
{ href: "/wehren", labelKey: "search.tabWehren" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab-Navigation der Suche (Fahrzeuge · Geräte · Wehren). Jeder Tab ist ein
|
||||||
|
* echter Link auf die jeweilige Route-Group-Seite; die aktive Route bestimmt
|
||||||
|
* den ausgewählten Tab. Die Tab-Inhalte rendern die jeweiligen Seiten selbst.
|
||||||
|
*/
|
||||||
|
export function SearchTabs() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const active = TABS.find((tab) => pathname.startsWith(tab.href))?.href;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs value={active}>
|
||||||
|
<TabsList aria-label={t("nav.fahrzeuge")}>
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<TabsTrigger key={tab.href} value={tab.href} asChild>
|
||||||
|
<Link href={tab.href}>{t(tab.labelKey)}</Link>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/search/StandortBar.tsx
Normal file
24
src/components/search/StandortBar.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { StandortInput } from "@/components/geo/standort-input";
|
||||||
|
import { useSearchUrl } from "./useSearchParams";
|
||||||
|
import type { Coordinates } from "@/lib/geo/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-Wrapper, der den gewählten Standort (Geolocation oder geocodierte
|
||||||
|
* Adresse) als `lat`/`lng` in die URL schreibt — ohne Reload. Die Server-Seite
|
||||||
|
* liest diese Parameter und sortiert die Treffer dann nach Eintreffzeit.
|
||||||
|
*/
|
||||||
|
export function StandortBar() {
|
||||||
|
const { setParams } = useSearchUrl();
|
||||||
|
|
||||||
|
const onResolved = (coords: Coordinates) => {
|
||||||
|
setParams({ lat: String(coords.lat), lng: String(coords.lng) });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<StandortInput onResolved={onResolved} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/search/facets/BooleanFacet.tsx
Normal file
58
src/components/search/facets/BooleanFacet.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
import type { FacetDef } from "@/lib/search/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
facet: FacetDef;
|
||||||
|
/** "ja" | "nein" | undefined (egal). */
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPTIONS = [
|
||||||
|
{ key: null, labelKey: "search.egal" as const },
|
||||||
|
{ key: "ja", labelKey: "search.ja" as const },
|
||||||
|
{ key: "nein", labelKey: "search.nein" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boolean-Facette als Tri-State-Umschalter (egal / Ja / Nein). "egal" entfernt
|
||||||
|
* den Filter; "ja"/"nein" schreiben den Wert.
|
||||||
|
*/
|
||||||
|
export function BooleanFacet({ facet, value, onChange }: Props) {
|
||||||
|
const current = value === "ja" || value === "nein" ? value : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="flex items-center justify-between gap-2">
|
||||||
|
<Label className="text-sm font-medium text-anthrazit">{facet.name}</Label>
|
||||||
|
<div
|
||||||
|
className="inline-flex overflow-hidden rounded border border-rand"
|
||||||
|
role="group"
|
||||||
|
aria-label={facet.name}
|
||||||
|
>
|
||||||
|
{OPTIONS.map((opt) => {
|
||||||
|
const active = current === opt.key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.labelKey}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => onChange(opt.key)}
|
||||||
|
className={cn(
|
||||||
|
"px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-navy text-white"
|
||||||
|
: "bg-white text-anthrazit/70 hover:bg-nebel",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(opt.labelKey)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/search/facets/EnumFacet.tsx
Normal file
56
src/components/search/facets/EnumFacet.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import type { FacetDef } from "@/lib/search/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
facet: FacetDef;
|
||||||
|
/** Aktueller URL-Wert als CSV der gewählten `wert`-Codes. */
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum-Facette als Multi-Select (Checkbox-Liste). Schreibt die gewählten Codes
|
||||||
|
* als CSV in die URL; leere Auswahl entfernt den Filter.
|
||||||
|
*/
|
||||||
|
export function EnumFacet({ facet, value, onChange }: Props) {
|
||||||
|
const optionen = facet.optionen ?? [];
|
||||||
|
const selected = React.useMemo(
|
||||||
|
() => new Set((value ?? "").split(",").map((s) => s.trim()).filter(Boolean)),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (optionen.length === 0) return null;
|
||||||
|
|
||||||
|
const toggle = (wert: string) => {
|
||||||
|
const next = new Set(selected);
|
||||||
|
if (next.has(wert)) next.delete(wert);
|
||||||
|
else next.add(wert);
|
||||||
|
const csv = [...next].join(",");
|
||||||
|
onChange(csv === "" ? null : csv);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-sm font-medium text-anthrazit">{facet.name}</Label>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{optionen.map((opt) => (
|
||||||
|
<label
|
||||||
|
key={opt.wert}
|
||||||
|
className="flex items-center gap-2 text-sm text-anthrazit/90"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-rand text-navy focus:ring-navy"
|
||||||
|
checked={selected.has(opt.wert)}
|
||||||
|
onChange={() => toggle(opt.wert)}
|
||||||
|
/>
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/components/search/facets/NumberRangeFacet.tsx
Normal file
90
src/components/search/facets/NumberRangeFacet.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
import type { FacetDef } from "@/lib/search/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
facet: FacetDef;
|
||||||
|
/** Aktueller URL-Wert `lo..hi` (oder undefined). */
|
||||||
|
value: string | undefined;
|
||||||
|
/** Schreibt den neuen `lo..hi`-Wert (null entfernt den Filter). */
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parse(value: string | undefined, min: number, max: number): [number, number] {
|
||||||
|
if (!value || !value.includes("..")) return [min, max];
|
||||||
|
const parts = value.split("..");
|
||||||
|
const loRaw = parts[0] ?? "";
|
||||||
|
const hiRaw = parts[1] ?? "";
|
||||||
|
const lo = loRaw === "" ? min : Number(loRaw);
|
||||||
|
const hi = hiRaw === "" ? max : Number(hiRaw);
|
||||||
|
return [Number.isFinite(lo) ? lo : min, Number.isFinite(hi) ? hi : max];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number-Facette als Doppel-Slider plus exakte Zahlenfelder. Schreibt `lo..hi`
|
||||||
|
* in die URL; entspricht der Range den Facetten-Grenzen, wird der Filter entfernt.
|
||||||
|
*/
|
||||||
|
export function NumberRangeFacet({ facet, value, onChange }: Props) {
|
||||||
|
const min = facet.min ?? 0;
|
||||||
|
const max = facet.max ?? 100;
|
||||||
|
const [lo, hi] = parse(value, min, max);
|
||||||
|
|
||||||
|
const emit = React.useCallback(
|
||||||
|
(nlo: number, nhi: number) => {
|
||||||
|
if (nlo <= min && nhi >= max) {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loPart = nlo <= min ? "" : String(nlo);
|
||||||
|
const hiPart = nhi >= max ? "" : String(nhi);
|
||||||
|
onChange(`${loPart}..${hiPart}`);
|
||||||
|
},
|
||||||
|
[onChange, min, max],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (max <= min) return null; // keine sinnvolle Spanne
|
||||||
|
|
||||||
|
const unit = facet.einheit ? ` (${facet.einheit})` : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset className="flex flex-col gap-2">
|
||||||
|
<Label className="text-sm font-medium text-anthrazit">
|
||||||
|
{facet.name}
|
||||||
|
{unit}
|
||||||
|
</Label>
|
||||||
|
<Slider
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={[lo, hi]}
|
||||||
|
onValueChange={(v) => emit(v[0] ?? min, v[1] ?? max)}
|
||||||
|
aria-label={facet.name}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
aria-label={`${facet.name} ${t("search.von")}`}
|
||||||
|
value={lo}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
onChange={(e) => emit(Number(e.target.value), hi)}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
<span className="text-anthrazit/50">–</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
aria-label={`${facet.name} ${t("search.bis")}`}
|
||||||
|
value={hi}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
onChange={(e) => emit(lo, Number(e.target.value))}
|
||||||
|
className="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/components/search/results/BrigadeResultRow.tsx
Normal file
19
src/components/search/results/BrigadeResultRow.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { BrigadeHit } from "@/lib/search/types";
|
||||||
|
|
||||||
|
/** Eine Wehr-Trefferzeile (Name, Ort, PLZ). */
|
||||||
|
export function BrigadeResultRow({ hit }: { hit: BrigadeHit }) {
|
||||||
|
const ortLine = [hit.plz, hit.ort].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<li className="flex items-center justify-between gap-4 border-b border-rand px-1 py-3 last:border-0">
|
||||||
|
<a
|
||||||
|
href={`/wehren/${hit.brigadeId}`}
|
||||||
|
className="min-w-0 truncate font-medium text-navy hover:underline"
|
||||||
|
>
|
||||||
|
{hit.name}
|
||||||
|
</a>
|
||||||
|
{ortLine ? (
|
||||||
|
<span className="shrink-0 text-sm text-anthrazit/60">{ortLine}</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/search/results/EquipmentResultRow.tsx
Normal file
31
src/components/search/results/EquipmentResultRow.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { StatusBadge } from "@/components/ui/badge";
|
||||||
|
import { EtaBadge } from "@/components/geo/eta-badge";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
import type { EtaResult } from "@/lib/geo/types";
|
||||||
|
import type { SearchHit } from "@/lib/search/types";
|
||||||
|
|
||||||
|
export type EquipmentRowData = SearchHit & { eta?: EtaResult };
|
||||||
|
|
||||||
|
/** Eine Geräte-Trefferzeile (kein Funkrufname). */
|
||||||
|
export function EquipmentResultRow({ hit }: { hit: EquipmentRowData }) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-center justify-between gap-4 border-b border-rand px-1 py-3 last:border-0">
|
||||||
|
<a
|
||||||
|
href={`/geraete/${hit.entityId}`}
|
||||||
|
className="min-w-0 truncate font-medium text-navy hover:underline"
|
||||||
|
>
|
||||||
|
{hit.name}
|
||||||
|
</a>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<StatusBadge status={hit.status} />
|
||||||
|
{hit.eta ? (
|
||||||
|
<EtaBadge eta={hit.eta} />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-anthrazit/40">
|
||||||
|
{t("search.eintreffzeitOffen")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/search/results/ResultList.tsx
Normal file
28
src/components/search/results/ResultList.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generische Ergebnisliste mit Empty-State. `count` wird als Trefferzahl
|
||||||
|
* angezeigt; `children` sind die vorgerenderten `<li>`-Zeilen.
|
||||||
|
*/
|
||||||
|
export function ResultList({
|
||||||
|
count,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
count: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section aria-label={t("search.ergebnisse")} className="flex flex-col gap-2">
|
||||||
|
<p className="text-xs text-anthrazit/60">
|
||||||
|
{count} {t("search.treffer")}
|
||||||
|
</p>
|
||||||
|
{count === 0 ? (
|
||||||
|
<p className="rounded border border-dashed border-rand px-4 py-8 text-center text-sm text-anthrazit/60">
|
||||||
|
{t("search.keineTreffer")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="rounded border border-rand bg-white px-3">{children}</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/search/results/VehicleResultRow.tsx
Normal file
40
src/components/search/results/VehicleResultRow.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { StatusBadge } from "@/components/ui/badge";
|
||||||
|
import { EtaBadge } from "@/components/geo/eta-badge";
|
||||||
|
import { t } from "@/lib/i18n/de";
|
||||||
|
import type { EtaResult } from "@/lib/geo/types";
|
||||||
|
import type { SearchHit } from "@/lib/search/types";
|
||||||
|
|
||||||
|
export type VehicleRowData = SearchHit & { eta?: EtaResult };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Fahrzeug-Trefferzeile. Zeigt Name, Funkrufname, Status und — sobald ein
|
||||||
|
* Standort gewählt wurde — die Eintreffzeit. Ohne Standort bleibt der ETA-Slot
|
||||||
|
* offen (Hinweistext), die Liste ist dann unsortiert.
|
||||||
|
*/
|
||||||
|
export function VehicleResultRow({ hit }: { hit: VehicleRowData }) {
|
||||||
|
return (
|
||||||
|
<li className="flex items-center justify-between gap-4 border-b border-rand px-1 py-3 last:border-0">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<a
|
||||||
|
href={`/fahrzeuge/${hit.entityId}`}
|
||||||
|
className="truncate font-medium text-navy hover:underline"
|
||||||
|
>
|
||||||
|
{hit.name}
|
||||||
|
</a>
|
||||||
|
<p className="truncate text-xs text-anthrazit/60">
|
||||||
|
{hit.funkrufname ?? t("search.keinFunkrufname")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<StatusBadge status={hit.status} />
|
||||||
|
{hit.eta ? (
|
||||||
|
<EtaBadge eta={hit.eta} />
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-anthrazit/40">
|
||||||
|
{t("search.eintreffzeitOffen")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/components/search/useSearchParams.tsx
Normal file
88
src/components/search/useSearchParams.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest und schreibt die Such-/Filter-Parameter in die URL-Query OHNE Reload
|
||||||
|
* (`router.replace`, scroll: false). Textänderungen werden debounced, damit die
|
||||||
|
* URL nicht bei jedem Tastendruck neu geschrieben wird. Die URL ist die einzige
|
||||||
|
* Quelle der Wahrheit (teilbar, reload-stabil — Plan WS5 Verifikation 9).
|
||||||
|
*/
|
||||||
|
export function useSearchUrl(debounceMs = 350) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const params = useSearchParams();
|
||||||
|
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const current = React.useMemo(
|
||||||
|
() => new URLSearchParams(params?.toString() ?? ""),
|
||||||
|
[params],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commit = React.useCallback(
|
||||||
|
(next: URLSearchParams) => {
|
||||||
|
const qs = next.toString();
|
||||||
|
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||||
|
},
|
||||||
|
[router, pathname],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Setzt (oder entfernt bei null/leer) einen Parameter sofort. */
|
||||||
|
const setParam = React.useCallback(
|
||||||
|
(key: string, value: string | null) => {
|
||||||
|
const next = new URLSearchParams(current.toString());
|
||||||
|
if (value === null || value === "") next.delete(key);
|
||||||
|
else next.set(key, value);
|
||||||
|
commit(next);
|
||||||
|
},
|
||||||
|
[current, commit],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Setzt mehrere Parameter atomar (verhindert Race über stale `current`). */
|
||||||
|
const setParams = React.useCallback(
|
||||||
|
(entries: Record<string, string | null>) => {
|
||||||
|
const next = new URLSearchParams(current.toString());
|
||||||
|
for (const [key, value] of Object.entries(entries)) {
|
||||||
|
if (value === null || value === "") next.delete(key);
|
||||||
|
else next.set(key, value);
|
||||||
|
}
|
||||||
|
commit(next);
|
||||||
|
},
|
||||||
|
[current, commit],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Wie setParam, aber debounced (für Texteingaben). */
|
||||||
|
const setParamDebounced = React.useCallback(
|
||||||
|
(key: string, value: string | null) => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
const snapshot = new URLSearchParams(current.toString());
|
||||||
|
timer.current = setTimeout(() => {
|
||||||
|
if (value === null || value === "") snapshot.delete(key);
|
||||||
|
else snapshot.set(key, value);
|
||||||
|
commit(snapshot);
|
||||||
|
}, debounceMs);
|
||||||
|
},
|
||||||
|
[current, commit, debounceMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Entfernt alle `f.*`-Filter sowie `bereit`, behält `q` und Standort. */
|
||||||
|
const resetFilters = React.useCallback(() => {
|
||||||
|
const next = new URLSearchParams();
|
||||||
|
const q = current.get("q");
|
||||||
|
const lat = current.get("lat");
|
||||||
|
const lng = current.get("lng");
|
||||||
|
if (q) next.set("q", q);
|
||||||
|
if (lat) next.set("lat", lat);
|
||||||
|
if (lng) next.set("lng", lng);
|
||||||
|
commit(next);
|
||||||
|
}, [current, commit]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timer.current) clearTimeout(timer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { params: current, setParam, setParams, setParamDebounced, resetFilters };
|
||||||
|
}
|
||||||
78
src/lib/admin/codes.ts
Normal file
78
src/lib/admin/codes.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Fahrzeug-Code-/Allrad-Namensregel (geteiltes Modul).
|
||||||
|
*
|
||||||
|
* EIGENTUM: Admin-Workstream (Phase 4) ist der fachliche Eigentümer dieser
|
||||||
|
* Datei. Der Such-Workstream IMPORTIERT `expandNameQuery` von hier (Plan WS5,
|
||||||
|
* "Allrad-Logik: Import aus @/lib/admin/codes"), implementiert die Regel also
|
||||||
|
* NICHT doppelt. Diese Datei ist bewusst rein (keine DB-/IO-Abhängigkeit),
|
||||||
|
* damit sie ohne laufendes Postgres testbar ist.
|
||||||
|
*
|
||||||
|
* Allrad-Namensregel (NÖ LFV, siehe docs/reference/fahrzeug-katalog-noelfv.md):
|
||||||
|
* Bei der Allradausführung wird das "A" IN die Abkürzung eingeschoben — also
|
||||||
|
* "HLFA 3" statt "HLF 3 A". Die Allradform bezeichnet DIESELBE Vorlage; die
|
||||||
|
* Suche behandelt "HLF 3" und "HLFA 3" gleich. Allrad bleibt zusätzlich über
|
||||||
|
* das Merkmal `Allradantrieb = Ja` filterbar. Beispiele:
|
||||||
|
* "HLFA 3" -> nameLikes: ["HLFA 3", "HLF 3"], allradImpliziert: true
|
||||||
|
* "HLFA 1 W" -> nameLikes: ["HLFA 1 W", "HLF 1 W"], allradImpliziert: true
|
||||||
|
* "MTFA" -> nameLikes: ["MTFA", "MTF"], allradImpliziert: true
|
||||||
|
* "HLF 3" -> nameLikes: ["HLF 3"], allradImpliziert: false
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ExpandedNameQuery {
|
||||||
|
/** Alle Namensvarianten, nach denen (OR-verknüpft, case-insensitiv) gesucht wird. */
|
||||||
|
nameLikes: string[];
|
||||||
|
/** true, wenn der Eingabetext die Allradausführung (eingeschobenes "A") impliziert. */
|
||||||
|
allradImpliziert: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erkennt das in eine Fahrzeug-Abkürzung eingeschobene "A" (Allrad) und liefert
|
||||||
|
* sowohl die Originalschreibweise als auch die Grundform ohne "A". Die Heuristik
|
||||||
|
* arbeitet auf dem ERSTEN Token (der Abkürzung) und lässt den Rest (z. B. " 1 W",
|
||||||
|
* " 3", " 2000") unverändert.
|
||||||
|
*
|
||||||
|
* Erkennungsregel: Ein Großbuchstaben-Token, das auf "FA" endet und dessen
|
||||||
|
* Grundform (das "A" am Ende entfernt) auf "F" endet (HLF/MTF/TLF/RLF/KLF/...).
|
||||||
|
* Damit greift die Regel für HLFA/MTFA/TLFA/RLFA/KLFA/GTLFA usw., nicht aber
|
||||||
|
* für Tokens, die ohnehin auf "FA" enden müssten ohne Allradbezug.
|
||||||
|
*/
|
||||||
|
export function expandNameQuery(raw: string): ExpandedNameQuery {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === "") return { nameLikes: [], allradImpliziert: false };
|
||||||
|
|
||||||
|
// Normalisiere Mehrfach-Leerzeichen für den Vergleich, behalte aber die
|
||||||
|
// Eingabe als erste (exakte) Variante bei.
|
||||||
|
const normalized = trimmed.replace(/\s+/g, " ");
|
||||||
|
const spaceIdx = normalized.indexOf(" ");
|
||||||
|
const abk = spaceIdx === -1 ? normalized : normalized.slice(0, spaceIdx);
|
||||||
|
const rest = spaceIdx === -1 ? "" : normalized.slice(spaceIdx); // inkl. führendem Leerzeichen
|
||||||
|
|
||||||
|
const grundform = entferneAllradInfix(abk);
|
||||||
|
if (grundform === null) {
|
||||||
|
return { nameLikes: [normalized], allradImpliziert: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const grundVariante = grundform + rest;
|
||||||
|
// Original zuerst, dann Grundform; Duplikate vermeiden.
|
||||||
|
const nameLikes =
|
||||||
|
grundVariante === normalized ? [normalized] : [normalized, grundVariante];
|
||||||
|
return { nameLikes, allradImpliziert: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die Grundform einer Allrad-Abkürzung (entfernt das eingeschobene "A")
|
||||||
|
* oder `null`, wenn das Token keine erkennbare Allradausführung ist.
|
||||||
|
*
|
||||||
|
* "HLFA" -> "HLF", "MTFA" -> "MTF", "TLFA" -> "TLF", "RLFA" -> "RLF".
|
||||||
|
* "HLF" -> null (kein "A"), "MTF" -> null, "VF" -> null.
|
||||||
|
*/
|
||||||
|
function entferneAllradInfix(abk: string): string | null {
|
||||||
|
// Token muss aus Großbuchstaben (ggf. mit Bindestrich) bestehen.
|
||||||
|
if (!/^[A-ZÄÖÜ-]+$/.test(abk)) return null;
|
||||||
|
if (!abk.endsWith("FA")) return null;
|
||||||
|
const grund = abk.slice(0, -1); // entfernt das End-"A" -> endet auf "F"
|
||||||
|
if (!grund.endsWith("F")) return null;
|
||||||
|
// Grundform muss eine echte Fahrzeugabkürzung sein (≥2 Zeichen, endet auf F).
|
||||||
|
if (grund.length < 2) return null;
|
||||||
|
return grund;
|
||||||
|
}
|
||||||
18
src/lib/db/indexes-trgm.sql
Normal file
18
src/lib/db/indexes-trgm.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- WS5 (Suche): NUR Trigram-Indizes auf den frei durchsuchbaren Textspalten von
|
||||||
|
-- `vehicles`. Die `merkmal_values`-Indizes (mv_merkmal_num_idx etc.) gehören dem
|
||||||
|
-- DB-Workstream und werden hier NICHT angelegt (Querschnittsstandard 8: genau ein
|
||||||
|
-- Migrations-Eigentümer; dies ist eine Folge-Migration nach Merge des Schemas).
|
||||||
|
--
|
||||||
|
-- Idempotent: pg_trgm-Extension und Indizes mit IF NOT EXISTS
|
||||||
|
-- (Querschnittsstandard 7).
|
||||||
|
--
|
||||||
|
-- Anwendung: über eine vom DB-Workstream generierte Folge-Migration einspielen
|
||||||
|
-- (`drizzle-kit generate` nach Merge), nicht als paralleles 0000_*.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vehicles_name_trgm
|
||||||
|
ON vehicles USING gin (name gin_trgm_ops);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vehicles_funkrufname_trgm
|
||||||
|
ON vehicles USING gin (funkrufname gin_trgm_ops);
|
||||||
@@ -30,6 +30,26 @@ export const de = {
|
|||||||
luftlinie: "Luftlinie (geschätzt)",
|
luftlinie: "Luftlinie (geschätzt)",
|
||||||
adresse: "Adresse",
|
adresse: "Adresse",
|
||||||
adressePlaceholder: "Adresse oder Ort",
|
adressePlaceholder: "Adresse oder Ort",
|
||||||
|
suchbegriff: "Suchbegriff",
|
||||||
|
suchbegriffPlaceholder: "Name oder Funkrufname …",
|
||||||
|
nameOrtPlz: "Name, Ort oder PLZ …",
|
||||||
|
filter: "Filter",
|
||||||
|
filterZuruecksetzen: "Filter zurücksetzen",
|
||||||
|
keineFilter: "Keine Filter verfügbar.",
|
||||||
|
nurEinsatzbereit: "Nur einsatzbereit",
|
||||||
|
von: "von",
|
||||||
|
bis: "bis",
|
||||||
|
egal: "egal",
|
||||||
|
ja: "Ja",
|
||||||
|
nein: "Nein",
|
||||||
|
tabFahrzeuge: "Fahrzeuge",
|
||||||
|
tabGeraete: "Geräte",
|
||||||
|
tabWehren: "Wehren",
|
||||||
|
treffer: "Treffer",
|
||||||
|
standort: "Standort für Eintreffzeit",
|
||||||
|
keinFunkrufname: "kein Funkrufname",
|
||||||
|
eintreffzeitOffen: "Eintreffzeit: Standort wählen",
|
||||||
|
ergebnisse: "Ergebnisse",
|
||||||
},
|
},
|
||||||
detail: {
|
detail: {
|
||||||
eckdaten: "Eckdaten",
|
eckdaten: "Eckdaten",
|
||||||
|
|||||||
45
src/lib/search/__tests__/codes.test.ts
Normal file
45
src/lib/search/__tests__/codes.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { expandNameQuery } from "@/lib/admin/codes";
|
||||||
|
|
||||||
|
describe("expandNameQuery (Allrad-Namensregel)", () => {
|
||||||
|
it("expandiert HLFA 3 zu HLFA 3 + HLF 3 und impliziert Allrad", () => {
|
||||||
|
const r = expandNameQuery("HLFA 3");
|
||||||
|
expect(r.nameLikes).toEqual(["HLFA 3", "HLF 3"]);
|
||||||
|
expect(r.allradImpliziert).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expandiert HLFA 1 W korrekt (Rest bleibt erhalten)", () => {
|
||||||
|
const r = expandNameQuery("HLFA 1 W");
|
||||||
|
expect(r.nameLikes).toEqual(["HLFA 1 W", "HLF 1 W"]);
|
||||||
|
expect(r.allradImpliziert).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expandiert MTFA (ohne Suffix) zu MTFA + MTF", () => {
|
||||||
|
const r = expandNameQuery("MTFA");
|
||||||
|
expect(r.nameLikes).toEqual(["MTFA", "MTF"]);
|
||||||
|
expect(r.allradImpliziert).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lässt HLF 3 unverändert (kein Allrad)", () => {
|
||||||
|
const r = expandNameQuery("HLF 3");
|
||||||
|
expect(r.nameLikes).toEqual(["HLF 3"]);
|
||||||
|
expect(r.allradImpliziert).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalisiert Mehrfach-Leerzeichen", () => {
|
||||||
|
const r = expandNameQuery("HLFA 3");
|
||||||
|
expect(r.nameLikes).toEqual(["HLFA 3", "HLF 3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behandelt leere Eingabe ohne Treffer", () => {
|
||||||
|
const r = expandNameQuery(" ");
|
||||||
|
expect(r.nameLikes).toEqual([]);
|
||||||
|
expect(r.allradImpliziert).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behandelt Freitext (kein Code) als einzelnen Like ohne Allrad", () => {
|
||||||
|
const r = expandNameQuery("Florian Tulln 1");
|
||||||
|
expect(r.nameLikes).toEqual(["Florian Tulln 1"]);
|
||||||
|
expect(r.allradImpliziert).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
89
src/lib/search/__tests__/parse-params.test.ts
Normal file
89
src/lib/search/__tests__/parse-params.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseSearchParams } from "@/lib/search/parse-params";
|
||||||
|
import type { FacetDef } from "@/lib/search/types";
|
||||||
|
|
||||||
|
const TANK = "11111111-1111-1111-1111-111111111111";
|
||||||
|
const ALLRAD = "22222222-2222-2222-2222-222222222222";
|
||||||
|
const PUMPE = "33333333-3333-3333-3333-333333333333";
|
||||||
|
const UNKNOWN = "99999999-9999-9999-9999-999999999999";
|
||||||
|
|
||||||
|
const facets: FacetDef[] = [
|
||||||
|
{ merkmalId: TANK, name: "Löschwassertank", typ: "number", einheit: "l", min: 0, max: 14000 },
|
||||||
|
{ merkmalId: ALLRAD, name: "Allradantrieb", typ: "boolean", einheit: null },
|
||||||
|
{
|
||||||
|
merkmalId: PUMPE,
|
||||||
|
name: "Feuerlöschpumpe",
|
||||||
|
typ: "enum",
|
||||||
|
einheit: null,
|
||||||
|
optionen: [
|
||||||
|
{ wert: "fpn1000", label: "FPN 10-1000" },
|
||||||
|
{ wert: "fpn2000", label: "FPN 10-2000" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("parseSearchParams", () => {
|
||||||
|
it("parst number-Range lo..hi, boolean ja, bereit=1", () => {
|
||||||
|
const r = parseSearchParams(
|
||||||
|
{ [`f.${TANK}`]: "2000..4000", [`f.${ALLRAD}`]: "ja", bereit: "1" },
|
||||||
|
facets,
|
||||||
|
);
|
||||||
|
expect(r.nurEinsatzbereit).toBe(true);
|
||||||
|
expect(r.filter).toContainEqual({ typ: "number", merkmalId: TANK, gte: 2000, lte: 4000 });
|
||||||
|
expect(r.filter).toContainEqual({ typ: "boolean", merkmalId: ALLRAD, eq: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parst enum als CSV (nur bekannte Optionen)", () => {
|
||||||
|
const r = parseSearchParams({ [`f.${PUMPE}`]: "fpn2000,unbekannt,fpn1000" }, facets);
|
||||||
|
expect(r.filter).toContainEqual({ typ: "enum", merkmalId: PUMPE, in: ["fpn2000", "fpn1000"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parst offene Range lo.. (nur gte)", () => {
|
||||||
|
const r = parseSearchParams({ [`f.${TANK}`]: "2000.." }, facets);
|
||||||
|
expect(r.filter).toContainEqual({ typ: "number", merkmalId: TANK, gte: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parst offene Range ..hi (nur lte)", () => {
|
||||||
|
const r = parseSearchParams({ [`f.${TANK}`]: "..4000" }, facets);
|
||||||
|
expect(r.filter).toContainEqual({ typ: "number", merkmalId: TANK, lte: 4000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parst boolean nein als eq=false", () => {
|
||||||
|
const r = parseSearchParams({ [`f.${ALLRAD}`]: "nein" }, facets);
|
||||||
|
expect(r.filter).toContainEqual({ typ: "boolean", merkmalId: ALLRAD, eq: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verwirft unbekannte merkmalId still", () => {
|
||||||
|
const r = parseSearchParams({ [`f.${UNKNOWN}`]: "foo" }, facets);
|
||||||
|
expect(r.filter).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verwirft Nicht-uuid-Keys und Ungültiges still", () => {
|
||||||
|
const r = parseSearchParams(
|
||||||
|
{ "f.nichtuuid": "1..2", [`f.${TANK}`]: "abc..def", [`f.${ALLRAD}`]: "vielleicht" },
|
||||||
|
facets,
|
||||||
|
);
|
||||||
|
expect(r.filter).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verwirft enum ohne gültige Optionen still", () => {
|
||||||
|
const r = parseSearchParams({ [`f.${PUMPE}`]: "nixda,auchnicht" }, facets);
|
||||||
|
expect(r.filter).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("übernimmt q getrimmt, leeres q -> undefined", () => {
|
||||||
|
expect(parseSearchParams({ q: " HLF 3 " }, facets).q).toBe("HLF 3");
|
||||||
|
expect(parseSearchParams({ q: " " }, facets).q).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bereit !== '1' -> nurEinsatzbereit false", () => {
|
||||||
|
expect(parseSearchParams({}, facets).nurEinsatzbereit).toBe(false);
|
||||||
|
expect(parseSearchParams({ bereit: "0" }, facets).nurEinsatzbereit).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("akzeptiert string[]-Werte (Next searchParams) und nimmt den ersten", () => {
|
||||||
|
const r = parseSearchParams({ q: ["a", "b"], bereit: ["1"] }, facets);
|
||||||
|
expect(r.q).toBe("a");
|
||||||
|
expect(r.nurEinsatzbereit).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
74
src/lib/search/__tests__/query-vehicles.test.ts
Normal file
74
src/lib/search/__tests__/query-vehicles.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { PgDialect } from "drizzle-orm/pg-core";
|
||||||
|
import { buildFilterExists, buildNameCondition } from "@/lib/search/query-vehicles";
|
||||||
|
import { vehicles } from "@/db/schema/assets";
|
||||||
|
import type { FilterValue } from "@/lib/search/types";
|
||||||
|
|
||||||
|
const dialect = new PgDialect();
|
||||||
|
const TANK = "11111111-1111-1111-1111-111111111111";
|
||||||
|
const ALLRAD = "22222222-2222-2222-2222-222222222222";
|
||||||
|
const PUMPE = "33333333-3333-3333-3333-333333333333";
|
||||||
|
|
||||||
|
function render(query: ReturnType<typeof buildFilterExists>) {
|
||||||
|
const { sql, params } = dialect.sqlToQuery(query);
|
||||||
|
return { sql, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("buildNameCondition", () => {
|
||||||
|
it("liefert undefined ohne Suchtext", () => {
|
||||||
|
expect(buildNameCondition(undefined)).toBeUndefined();
|
||||||
|
expect(buildNameCondition(" ")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("expandiert HLFA 3 zu HLF 3 (Allrad) und sucht auf name + funkrufname", () => {
|
||||||
|
const cond = buildNameCondition("HLFA 3");
|
||||||
|
expect(cond).toBeDefined();
|
||||||
|
const { sql, params } = render(cond!);
|
||||||
|
expect(sql).toContain("ilike");
|
||||||
|
// 2 Varianten (HLFA 3, HLF 3) x 2 Spalten (name, funkrufname) = 4 Patterns
|
||||||
|
expect(params).toContain("%HLFA 3%");
|
||||||
|
expect(params).toContain("%HLF 3%");
|
||||||
|
expect(params.filter((p) => p === "%HLF 3%").length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildFilterExists", () => {
|
||||||
|
it("number gte/lte erzeugt EXISTS mit value_num Range", () => {
|
||||||
|
const f: FilterValue = { typ: "number", merkmalId: TANK, gte: 2000, lte: 4000 };
|
||||||
|
const { sql, params } = render(buildFilterExists(f, "vehicle", vehicles.id));
|
||||||
|
expect(sql.toLowerCase()).toContain("exists");
|
||||||
|
expect(sql.toLowerCase()).toContain("value_num");
|
||||||
|
expect(params).toContain(TANK);
|
||||||
|
expect(params).toContain(2000);
|
||||||
|
expect(params).toContain(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("number nur gte erzeugt nur untere Grenze", () => {
|
||||||
|
const f: FilterValue = { typ: "number", merkmalId: TANK, gte: 2000 };
|
||||||
|
const { params } = render(buildFilterExists(f, "vehicle", vehicles.id));
|
||||||
|
expect(params).toContain(2000);
|
||||||
|
expect(params).not.toContain(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("boolean erzeugt value_bool-Gleichheit", () => {
|
||||||
|
const f: FilterValue = { typ: "boolean", merkmalId: ALLRAD, eq: true };
|
||||||
|
const { sql, params } = render(buildFilterExists(f, "vehicle", vehicles.id));
|
||||||
|
expect(sql.toLowerCase()).toContain("value_bool");
|
||||||
|
expect(params).toContain(ALLRAD);
|
||||||
|
expect(params).toContain(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enum erzeugt value_text IN (...)", () => {
|
||||||
|
const f: FilterValue = { typ: "enum", merkmalId: PUMPE, in: ["fpn1000", "fpn2000"] };
|
||||||
|
const { sql, params } = render(buildFilterExists(f, "vehicle", vehicles.id));
|
||||||
|
expect(sql.toLowerCase()).toContain("value_text");
|
||||||
|
expect(params).toContain("fpn1000");
|
||||||
|
expect(params).toContain("fpn2000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt den richtigen entity_typ", () => {
|
||||||
|
const f: FilterValue = { typ: "boolean", merkmalId: ALLRAD, eq: false };
|
||||||
|
const { params } = render(buildFilterExists(f, "equipment", vehicles.id));
|
||||||
|
expect(params).toContain("equipment");
|
||||||
|
});
|
||||||
|
});
|
||||||
112
src/lib/search/facets.ts
Normal file
112
src/lib/search/facets.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { and, eq, inArray, asc, min as sqlMin, max as sqlMax, sql } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { merkmale, merkmalOptionen } from "@/db/schema/merkmale";
|
||||||
|
import { merkmalValues } from "@/db/schema/merkmal-values";
|
||||||
|
import type { EntityTyp, FacetDef, MerkmalTyp } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lädt die Facetten für das dynamische Filter-UI aus dem AKTIVEN Merkmal-Katalog.
|
||||||
|
*
|
||||||
|
* Auswahlkriterien (Plan WS5):
|
||||||
|
* - nur `status = 'active'` (kein `proposed`),
|
||||||
|
* - `geltungsbereich IN (entityTyp, 'both')`,
|
||||||
|
* - `typ <> 'text'` (Freitext wird nicht als Filter angeboten).
|
||||||
|
*
|
||||||
|
* Anreicherung:
|
||||||
|
* - number: beobachtetes min/max aus `merkmal_values` (für Slider-Grenzen),
|
||||||
|
* - enum: Optionen aus `merkmal_optionen`, nach `reihenfolge` sortiert.
|
||||||
|
*/
|
||||||
|
export async function getFacets(entityTyp: EntityTyp): Promise<FacetDef[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: merkmale.id,
|
||||||
|
name: merkmale.name,
|
||||||
|
typ: merkmale.typ,
|
||||||
|
einheit: merkmale.einheit,
|
||||||
|
})
|
||||||
|
.from(merkmale)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(merkmale.status, "active"),
|
||||||
|
inArray(merkmale.geltungsbereich, [entityTyp, "both"]),
|
||||||
|
sql`${merkmale.typ} <> 'text'`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(merkmale.name));
|
||||||
|
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
|
const numberIds = rows.filter((r) => r.typ === "number").map((r) => r.id);
|
||||||
|
const enumIds = rows.filter((r) => r.typ === "enum").map((r) => r.id);
|
||||||
|
|
||||||
|
const ranges = numberIds.length > 0 ? await loadRanges(entityTyp, numberIds) : new Map();
|
||||||
|
const optionen = enumIds.length > 0 ? await loadOptionen(enumIds) : new Map();
|
||||||
|
|
||||||
|
return rows.map((r): FacetDef => {
|
||||||
|
const base: FacetDef = {
|
||||||
|
merkmalId: r.id,
|
||||||
|
name: r.name,
|
||||||
|
typ: r.typ as MerkmalTyp,
|
||||||
|
einheit: r.einheit,
|
||||||
|
};
|
||||||
|
if (r.typ === "number") {
|
||||||
|
const range = ranges.get(r.id);
|
||||||
|
if (range) {
|
||||||
|
base.min = range.min ?? undefined;
|
||||||
|
base.max = range.max ?? undefined;
|
||||||
|
}
|
||||||
|
} else if (r.typ === "enum") {
|
||||||
|
base.optionen = optionen.get(r.id) ?? [];
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRanges(
|
||||||
|
entityTyp: EntityTyp,
|
||||||
|
merkmalIds: string[],
|
||||||
|
): Promise<Map<string, { min: number | null; max: number | null }>> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
merkmalId: merkmalValues.merkmalId,
|
||||||
|
min: sqlMin(merkmalValues.valueNum),
|
||||||
|
max: sqlMax(merkmalValues.valueNum),
|
||||||
|
})
|
||||||
|
.from(merkmalValues)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(merkmalValues.entityTyp, entityTyp),
|
||||||
|
inArray(merkmalValues.merkmalId, merkmalIds),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(merkmalValues.merkmalId);
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
rows.map((r) => [
|
||||||
|
r.merkmalId,
|
||||||
|
{ min: r.min == null ? null : Number(r.min), max: r.max == null ? null : Number(r.max) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOptionen(
|
||||||
|
merkmalIds: string[],
|
||||||
|
): Promise<Map<string, { wert: string; label: string }[]>> {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
merkmalId: merkmalOptionen.merkmalId,
|
||||||
|
wert: merkmalOptionen.wert,
|
||||||
|
label: merkmalOptionen.label,
|
||||||
|
})
|
||||||
|
.from(merkmalOptionen)
|
||||||
|
.where(inArray(merkmalOptionen.merkmalId, merkmalIds))
|
||||||
|
.orderBy(asc(merkmalOptionen.reihenfolge), asc(merkmalOptionen.label));
|
||||||
|
|
||||||
|
const out = new Map<string, { wert: string; label: string }[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const list = out.get(r.merkmalId) ?? [];
|
||||||
|
list.push({ wert: r.wert, label: r.label });
|
||||||
|
out.set(r.merkmalId, list);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
122
src/lib/search/parse-params.ts
Normal file
122
src/lib/search/parse-params.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { FacetDef, FilterValue, SearchParams } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rohe searchParams, wie Next.js sie liefert: jeder Wert kann fehlen, ein
|
||||||
|
* String oder ein String-Array sein.
|
||||||
|
*/
|
||||||
|
export type RawSearchParams = Record<
|
||||||
|
string,
|
||||||
|
string | string[] | undefined
|
||||||
|
>;
|
||||||
|
|
||||||
|
const uuidRe =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
const numberRe = /^-?\d+(\.\d+)?$/;
|
||||||
|
|
||||||
|
/** Nimmt bei String-Array den ersten Eintrag; normalisiert undefined/"". */
|
||||||
|
function first(v: string | string[] | undefined): string | undefined {
|
||||||
|
const s = Array.isArray(v) ? v[0] : v;
|
||||||
|
return s ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNum(s: string): number | undefined {
|
||||||
|
if (!numberRe.test(s)) return undefined;
|
||||||
|
const n = Number(s);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst eine `lo..hi`-Range. Beide Seiten optional, aber mindestens eine muss
|
||||||
|
* gesetzt und gültig sein. Liefert `undefined`, wenn nichts Gültiges entsteht.
|
||||||
|
*/
|
||||||
|
function parseRange(raw: string): { gte?: number; lte?: number } | undefined {
|
||||||
|
if (!raw.includes("..")) return undefined;
|
||||||
|
const parts = raw.split("..");
|
||||||
|
const loRaw = parts[0] ?? "";
|
||||||
|
const hiRaw = parts[1] ?? "";
|
||||||
|
const out: { gte?: number; lte?: number } = {};
|
||||||
|
if (loRaw !== "") {
|
||||||
|
const lo = parseNum(loRaw);
|
||||||
|
if (lo === undefined) return undefined;
|
||||||
|
out.gte = lo;
|
||||||
|
}
|
||||||
|
if (hiRaw !== "") {
|
||||||
|
const hi = parseNum(hiRaw);
|
||||||
|
if (hi === undefined) return undefined;
|
||||||
|
out.lte = hi;
|
||||||
|
}
|
||||||
|
if (out.gte === undefined && out.lte === undefined) return undefined;
|
||||||
|
if (out.gte !== undefined && out.lte !== undefined && out.gte > out.lte) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBool(raw: string): boolean | undefined {
|
||||||
|
if (raw === "ja") return true;
|
||||||
|
if (raw === "nein") return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst die rohen searchParams in typisierte `SearchParams`. Die `facets`
|
||||||
|
* liefern Typ und (für enum) gültige Optionen je `merkmalId`. ALLES Ungültige
|
||||||
|
* wird STILL verworfen (kein Throw) — die UI bleibt robust gegen URL-Manipulation.
|
||||||
|
*
|
||||||
|
* Konvention: `q` = Freitext, `bereit=1` = nur einsatzbereit,
|
||||||
|
* `f.<merkmalId>=...` = Filter (number `lo..hi`, enum CSV, boolean `ja/nein`).
|
||||||
|
*/
|
||||||
|
export function parseSearchParams(
|
||||||
|
raw: RawSearchParams,
|
||||||
|
facets: FacetDef[],
|
||||||
|
): SearchParams {
|
||||||
|
const facetById = new Map<string, FacetDef>(
|
||||||
|
facets.map((f) => [f.merkmalId, f]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const qRaw = first(raw.q)?.trim();
|
||||||
|
const q = qRaw ? qRaw : undefined;
|
||||||
|
const nurEinsatzbereit = first(raw.bereit) === "1";
|
||||||
|
|
||||||
|
const filter: FilterValue[] = [];
|
||||||
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
|
if (!key.startsWith("f.")) continue;
|
||||||
|
const merkmalId = key.slice(2);
|
||||||
|
if (!uuidRe.test(merkmalId)) continue;
|
||||||
|
const facet = facetById.get(merkmalId);
|
||||||
|
if (!facet) continue;
|
||||||
|
const v = first(value);
|
||||||
|
if (v === undefined) continue;
|
||||||
|
|
||||||
|
if (facet.typ === "number") {
|
||||||
|
const range = parseRange(v);
|
||||||
|
if (range) filter.push({ typ: "number", merkmalId, ...range });
|
||||||
|
} else if (facet.typ === "boolean") {
|
||||||
|
const b = parseBool(v);
|
||||||
|
if (b !== undefined) filter.push({ typ: "boolean", merkmalId, eq: b });
|
||||||
|
} else if (facet.typ === "enum") {
|
||||||
|
const allowed = new Set((facet.optionen ?? []).map((o) => o.wert));
|
||||||
|
const chosen = v
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s !== "" && allowed.has(s));
|
||||||
|
if (chosen.length > 0) filter.push({ typ: "enum", merkmalId, in: chosen });
|
||||||
|
}
|
||||||
|
// typ === "text" wird im Filter-UI nicht angeboten -> ignoriert.
|
||||||
|
}
|
||||||
|
|
||||||
|
return { q, nurEinsatzbereit, filter };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod-Schema für den Geocode-Standort, der optional aus der URL kommen kann
|
||||||
|
* (lat/lng). Bleibt streng, damit kein 500 entsteht.
|
||||||
|
*/
|
||||||
|
export const standortSchema = z
|
||||||
|
.object({
|
||||||
|
lat: z.coerce.number().min(-90).max(90),
|
||||||
|
lng: z.coerce.number().min(-180).max(180),
|
||||||
|
})
|
||||||
|
.partial();
|
||||||
40
src/lib/search/query-brigades.ts
Normal file
40
src/lib/search/query-brigades.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { and, eq, ilike, or, type SQL } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { brigades } from "@/db/schema/brigades";
|
||||||
|
import type { BrigadeHit } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wehren-Suche über Name / Ort / PLZ. Nur aktive Wehren. Liefert eine
|
||||||
|
* UNGEORDNETE Liste (ETA-Sortierung optional über den Geo-Adapter, falls
|
||||||
|
* Standort gesetzt).
|
||||||
|
*/
|
||||||
|
export async function searchBrigades(q?: string): Promise<BrigadeHit[]> {
|
||||||
|
const conds: SQL[] = [eq(brigades.aktiv, true)];
|
||||||
|
|
||||||
|
if (q && q.trim() !== "") {
|
||||||
|
const pattern = `%${q.trim()}%`;
|
||||||
|
const textCond = or(
|
||||||
|
ilike(brigades.name, pattern),
|
||||||
|
ilike(brigades.ort, pattern),
|
||||||
|
ilike(brigades.plz, pattern),
|
||||||
|
);
|
||||||
|
if (textCond) conds.push(textCond);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
brigadeId: brigades.id,
|
||||||
|
name: brigades.name,
|
||||||
|
ort: brigades.ort,
|
||||||
|
plz: brigades.plz,
|
||||||
|
})
|
||||||
|
.from(brigades)
|
||||||
|
.where(and(...conds));
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
brigadeId: r.brigadeId,
|
||||||
|
name: r.name,
|
||||||
|
ort: r.ort,
|
||||||
|
plz: r.plz,
|
||||||
|
}));
|
||||||
|
}
|
||||||
59
src/lib/search/query-equipment.ts
Normal file
59
src/lib/search/query-equipment.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { and, eq, ilike, or, type SQL } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { equipment } from "@/db/schema/assets";
|
||||||
|
import type { SearchHit, SearchParams } from "./types";
|
||||||
|
import { buildFilterExists } from "./query-vehicles";
|
||||||
|
|
||||||
|
/** Optionaler zusätzlicher Kategorie-Filter für die Geräte-Suche. */
|
||||||
|
export interface EquipmentSearchParams extends SearchParams {
|
||||||
|
categoryId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serverseitige Geräte-Suche. Wie Fahrzeuge, aber OHNE Allrad-Spezialfall und
|
||||||
|
* mit optionalem `categoryId`-Filter. Liefert UNGEORDNETE `SearchHit[]`.
|
||||||
|
*/
|
||||||
|
export async function searchEquipment(
|
||||||
|
params: EquipmentSearchParams,
|
||||||
|
): Promise<SearchHit[]> {
|
||||||
|
const conds: SQL[] = [];
|
||||||
|
|
||||||
|
if (params.q && params.q.trim() !== "") {
|
||||||
|
const pattern = `%${params.q.trim()}%`;
|
||||||
|
const nameCond = or(ilike(equipment.name, pattern));
|
||||||
|
if (nameCond) conds.push(nameCond);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.nurEinsatzbereit) {
|
||||||
|
conds.push(eq(equipment.status, "einsatzbereit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.categoryId) {
|
||||||
|
conds.push(eq(equipment.categoryId, params.categoryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const f of params.filter) {
|
||||||
|
conds.push(buildFilterExists(f, "equipment", equipment.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conds.length > 0 ? and(...conds) : undefined;
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
entityId: equipment.id,
|
||||||
|
brigadeId: equipment.brigadeId,
|
||||||
|
name: equipment.name,
|
||||||
|
status: equipment.status,
|
||||||
|
})
|
||||||
|
.from(equipment)
|
||||||
|
.where(where);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
entityTyp: "equipment" as const,
|
||||||
|
entityId: r.entityId,
|
||||||
|
brigadeId: r.brigadeId,
|
||||||
|
name: r.name,
|
||||||
|
funkrufname: null,
|
||||||
|
status: r.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
119
src/lib/search/query-vehicles.ts
Normal file
119
src/lib/search/query-vehicles.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
and,
|
||||||
|
or,
|
||||||
|
eq,
|
||||||
|
ilike,
|
||||||
|
gte,
|
||||||
|
lte,
|
||||||
|
inArray,
|
||||||
|
sql,
|
||||||
|
type SQL,
|
||||||
|
type AnyColumn,
|
||||||
|
} from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { vehicles } from "@/db/schema/assets";
|
||||||
|
import { merkmalValues } from "@/db/schema/merkmal-values";
|
||||||
|
import { expandNameQuery } from "@/lib/admin/codes";
|
||||||
|
import type { EntityTyp, FilterValue, SearchHit, SearchParams } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die Bedingung für EINEN Merkmal-Filter als korrelierte EXISTS-Subquery
|
||||||
|
* gegen `merkmal_values`. Mehrere Filter werden vom Aufrufer UND-verknüpft
|
||||||
|
* (je `merkmal_id` eine eigene EXISTS-Klausel), damit ein Fahrzeug ALLE
|
||||||
|
* Bedingungen erfüllen muss. Rein (nimmt die Entity-id-Spalte als Parameter),
|
||||||
|
* damit die Klausel-Erzeugung ohne laufende DB testbar ist.
|
||||||
|
*/
|
||||||
|
export function buildFilterExists(
|
||||||
|
filter: FilterValue,
|
||||||
|
entityTyp: EntityTyp,
|
||||||
|
entityIdCol: AnyColumn,
|
||||||
|
): SQL {
|
||||||
|
const base = and(
|
||||||
|
eq(merkmalValues.merkmalId, filter.merkmalId),
|
||||||
|
eq(merkmalValues.entityTyp, entityTyp),
|
||||||
|
sql`${merkmalValues.entityId} = ${entityIdCol}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let valueCond: SQL | undefined;
|
||||||
|
if (filter.typ === "number") {
|
||||||
|
const parts: SQL[] = [];
|
||||||
|
if (filter.gte !== undefined) parts.push(gte(merkmalValues.valueNum, filter.gte));
|
||||||
|
if (filter.lte !== undefined) parts.push(lte(merkmalValues.valueNum, filter.lte));
|
||||||
|
valueCond = parts.length > 0 ? and(...parts) : undefined;
|
||||||
|
} else if (filter.typ === "boolean") {
|
||||||
|
valueCond = eq(merkmalValues.valueBool, filter.eq);
|
||||||
|
} else {
|
||||||
|
// enum
|
||||||
|
valueCond =
|
||||||
|
filter.in.length > 0 ? inArray(merkmalValues.valueText, filter.in) : sql`false`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereInner = valueCond ? and(base, valueCond) : base;
|
||||||
|
return sql`EXISTS (SELECT 1 FROM ${merkmalValues} WHERE ${whereInner})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die OR-Bedingung über die expandierten Namensvarianten (HLFA->HLF) auf
|
||||||
|
* `name` UND `funkrufname`. Rein/testbar. Liefert `undefined`, wenn kein
|
||||||
|
* Suchtext gegeben ist.
|
||||||
|
*/
|
||||||
|
export function buildNameCondition(q: string | undefined): SQL | undefined {
|
||||||
|
if (!q || q.trim() === "") return undefined;
|
||||||
|
const { nameLikes } = expandNameQuery(q);
|
||||||
|
const likes = nameLikes.length > 0 ? nameLikes : [q.trim()];
|
||||||
|
const conds: SQL[] = [];
|
||||||
|
for (const like of likes) {
|
||||||
|
const pattern = `%${like}%`;
|
||||||
|
conds.push(ilike(vehicles.name, pattern));
|
||||||
|
conds.push(ilike(vehicles.funkrufname, pattern));
|
||||||
|
}
|
||||||
|
return or(...conds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serverseitige, typisierte Fahrzeug-Suche. Liefert UNGEORDNETE `SearchHit[]`
|
||||||
|
* (ETA-Sortierung übernimmt der Geo-Workstream). Filter sind UND-verknüpft
|
||||||
|
* (je Merkmal eine EXISTS-Klausel). Allrad-Regel via `expandNameQuery`.
|
||||||
|
*/
|
||||||
|
export async function searchVehicles(params: SearchParams): Promise<SearchHit[]> {
|
||||||
|
const conds: SQL[] = [];
|
||||||
|
|
||||||
|
const nameCond = buildNameCondition(params.q);
|
||||||
|
if (nameCond) conds.push(nameCond);
|
||||||
|
|
||||||
|
if (params.nurEinsatzbereit) {
|
||||||
|
conds.push(eq(vehicles.status, "einsatzbereit"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allrad implizit durch HLFA-Eingabe: zusätzlich Merkmal Allradantrieb=Ja,
|
||||||
|
// FALLS der Aufrufer eine entsprechende Allrad-merkmalId liefert. Da die
|
||||||
|
// merkmalId katalogabhängig ist, wird sie hier NICHT geraten; das Filter-UI
|
||||||
|
// setzt sie als regulären boolean-Filter. Die Namensexpansion allein deckt
|
||||||
|
// den Trefferraum bereits ab (HLF == HLFA derselben Vorlage).
|
||||||
|
|
||||||
|
for (const f of params.filter) {
|
||||||
|
conds.push(buildFilterExists(f, "vehicle", vehicles.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conds.length > 0 ? and(...conds) : undefined;
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
entityId: vehicles.id,
|
||||||
|
brigadeId: vehicles.brigadeId,
|
||||||
|
name: vehicles.name,
|
||||||
|
funkrufname: vehicles.funkrufname,
|
||||||
|
status: vehicles.status,
|
||||||
|
})
|
||||||
|
.from(vehicles)
|
||||||
|
.where(where);
|
||||||
|
|
||||||
|
return rows.map((r) => ({
|
||||||
|
entityTyp: "vehicle" as const,
|
||||||
|
entityId: r.entityId,
|
||||||
|
brigadeId: r.brigadeId,
|
||||||
|
name: r.name,
|
||||||
|
funkrufname: r.funkrufname,
|
||||||
|
status: r.status,
|
||||||
|
}));
|
||||||
|
}
|
||||||
51
src/lib/search/types.ts
Normal file
51
src/lib/search/types.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export type MerkmalTyp = "number" | "enum" | "boolean" | "text";
|
||||||
|
export type EntityTyp = "vehicle" | "equipment";
|
||||||
|
export type AssetStatus = "einsatzbereit" | "wartung" | "ausser_dienst";
|
||||||
|
|
||||||
|
/** Eine aus dem aktiven Merkmal-Katalog abgeleitete Facette für das Filter-UI. */
|
||||||
|
export interface FacetDef {
|
||||||
|
merkmalId: string;
|
||||||
|
name: string;
|
||||||
|
typ: MerkmalTyp;
|
||||||
|
einheit: string | null;
|
||||||
|
/** Nur bei `typ === "number"`: beobachtetes Minimum/Maximum aus merkmal_values. */
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
/** Nur bei `typ === "enum"`: vorhandene Optionen, sortiert. */
|
||||||
|
optionen?: { wert: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein einzelner aktiver Filter. Diskriminiert über `typ`. */
|
||||||
|
export type FilterValue =
|
||||||
|
| { typ: "number"; merkmalId: string; gte?: number; lte?: number }
|
||||||
|
| { typ: "enum"; merkmalId: string; in: string[] }
|
||||||
|
| { typ: "boolean"; merkmalId: string; eq: boolean };
|
||||||
|
|
||||||
|
/** Geparste, typisierte Suchparameter (aus searchParams). */
|
||||||
|
export interface SearchParams {
|
||||||
|
q?: string;
|
||||||
|
nurEinsatzbereit: boolean;
|
||||||
|
filter: FilterValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein UNGEORDNETER Suchtreffer. Die ETA-Sortierung passiert im Geo-Workstream
|
||||||
|
* über `searchHitsToGeoCandidates` + `orderByEintreffzeit`. IDs sind uuid
|
||||||
|
* (string), durchgängig.
|
||||||
|
*/
|
||||||
|
export interface SearchHit {
|
||||||
|
entityTyp: EntityTyp;
|
||||||
|
entityId: string;
|
||||||
|
brigadeId: string;
|
||||||
|
name: string;
|
||||||
|
funkrufname: string | null;
|
||||||
|
status: AssetStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Wehr-Treffer (Tab "Wehren"). */
|
||||||
|
export interface BrigadeHit {
|
||||||
|
brigadeId: string;
|
||||||
|
name: string;
|
||||||
|
ort: string | null;
|
||||||
|
plz: string | null;
|
||||||
|
}
|
||||||
82
tests/e2e/search.spec.ts
Normal file
82
tests/e2e/search.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E2E-Tests der dynamischen Suche & Filter (Workstream 5).
|
||||||
|
*
|
||||||
|
* NICHT in der Sandbox ausführbar (kein Server/DB) — deferred. Wird über
|
||||||
|
* `npm run test:e2e` gegen einen laufenden, geseedeten Server mit
|
||||||
|
* authentifizierter Session (Storage-State aus dem Auth-Setup) ausgeführt.
|
||||||
|
*
|
||||||
|
* Abgedeckte Garantien (Plan WS5 Verifikation 8–10):
|
||||||
|
* - /fahrzeuge rendert je active-Merkmal genau ein Filter-UI des richtigen Typs.
|
||||||
|
* - Filter ändern schreibt `f.<id>=…` bzw. `?bereit=1` in die URL (kein Reload),
|
||||||
|
* Trefferzahl sinkt, Reload liefert identisches Ergebnis.
|
||||||
|
* - Anonymer Aufruf von /fahrzeuge redirectet auf /login.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe("Suche – Default-deny", () => {
|
||||||
|
test("anonymer Aufruf von /fahrzeuge leitet auf /login um", async ({
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
// Frischer Kontext OHNE gespeicherte Session.
|
||||||
|
const ctx = await browser.newContext({ storageState: undefined });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto("/fahrzeuge");
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Suche – Tabs & URL-Sync (authentifiziert)", () => {
|
||||||
|
test("Tabs navigieren zwischen Fahrzeuge/Geräte/Wehren", async ({ page }) => {
|
||||||
|
await page.goto("/fahrzeuge");
|
||||||
|
await page.getByRole("tab", { name: "Geräte" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/geraete/);
|
||||||
|
await page.getByRole("tab", { name: "Wehren" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/wehren/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Status-Switch schreibt ?bereit=1 in die URL ohne Reload", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/fahrzeuge");
|
||||||
|
await page.getByLabel("Nur einsatzbereit").click();
|
||||||
|
await expect(page).toHaveURL(/bereit=1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Filter-Reset entfernt f.*-Parameter, behält q", async ({ page }) => {
|
||||||
|
await page.goto("/fahrzeuge?q=HLF&bereit=1");
|
||||||
|
await page.getByRole("button", { name: "Filter zurücksetzen" }).click();
|
||||||
|
await expect(page).toHaveURL(/q=HLF/);
|
||||||
|
await expect(page).not.toHaveURL(/bereit=1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Freitext-Suche schreibt q debounced in die URL", async ({ page }) => {
|
||||||
|
await page.goto("/fahrzeuge");
|
||||||
|
await page.getByLabel("Suchbegriff").fill("HLFA 3");
|
||||||
|
await expect(page).toHaveURL(/q=HLFA(\+|%20)3/, { timeout: 2000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Reload mit Filter-URL liefert identische Trefferzahl", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/fahrzeuge?bereit=1");
|
||||||
|
const before = await page.getByText(/Treffer/).textContent();
|
||||||
|
await page.reload();
|
||||||
|
const after = await page.getByText(/Treffer/).textContent();
|
||||||
|
expect(after).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Suche – dynamisches Filter-UI", () => {
|
||||||
|
test("jede Number-Facette hat einen Slider, jede Boolean einen Tri-State", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/fahrzeuge");
|
||||||
|
// Mindestens ein Slider (number) und eine Ja/Nein/egal-Gruppe (boolean).
|
||||||
|
await expect(page.getByRole("slider").first()).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "egal" }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user