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)",
|
||||
adresse: "Adresse",
|
||||
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: {
|
||||
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