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:
Matthias Hochmeister
2026-06-09 10:04:53 +02:00
parent 8f19d8e187
commit 0a7173ef38
29 changed files with 1674 additions and 0 deletions

View 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>
);
}

View 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
View 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");
}

View 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>
);
}

View 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
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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;
}

View 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);

View File

@@ -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",

View 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);
});
});

View 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);
});
});

View 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
View 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;
}

View 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();

View 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,
}));
}

View 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,
}));
}

View 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
View 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;
}