diff --git a/src/app/(app)/fahrzeuge/page.tsx b/src/app/(app)/fahrzeuge/page.tsx new file mode 100644 index 0000000..1bc2fe3 --- /dev/null +++ b/src/app/(app)/fahrzeuge/page.tsx @@ -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; + +/** 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; +}) { + // 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 ( +
+

{de.nav.fahrzeuge}

+ + + +
+ + + {rows.map((hit) => ( + + ))} + +
+
+ ); +} diff --git a/src/app/(app)/geraete/page.tsx b/src/app/(app)/geraete/page.tsx new file mode 100644 index 0000000..57fce58 --- /dev/null +++ b/src/app/(app)/geraete/page.tsx @@ -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; + +export default async function GeraetePage({ + searchParams, +}: { + searchParams: Promise; +}) { + // 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 ( +
+

{de.nav.geraete}

+ + +
+ + + {hits.map((hit) => ( + + ))} + +
+
+ ); +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx new file mode 100644 index 0000000..8d5dd4b --- /dev/null +++ b/src/app/(app)/page.tsx @@ -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"); +} diff --git a/src/app/(app)/wehren/page.tsx b/src/app/(app)/wehren/page.tsx new file mode 100644 index 0000000..26af3c4 --- /dev/null +++ b/src/app/(app)/wehren/page.tsx @@ -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; + +export default async function WehrenPage({ + searchParams, +}: { + searchParams: Promise; +}) { + // 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 ( +
+

{de.nav.wehren}

+ + + + {hits.map((hit) => ( + + ))} + +
+ ); +} diff --git a/src/components/search/FilterPanel.tsx b/src/components/search/FilterPanel.tsx new file mode 100644 index 0000000..85eeb0b --- /dev/null +++ b/src/components/search/FilterPanel.tsx @@ -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 ( + + ); +} + +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 ; + case "enum": + return ; + case "boolean": + return ; + default: + return null; // text wird nicht als Filter angeboten + } +} diff --git a/src/components/search/SearchBar.tsx b/src/components/search/SearchBar.tsx new file mode 100644 index 0000000..14cf29d --- /dev/null +++ b/src/components/search/SearchBar.tsx @@ -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 ( +
+ + { + setValue(e.target.value); + setParamDebounced("q", e.target.value); + }} + /> +
+ ); +} diff --git a/src/components/search/SearchTabs.tsx b/src/components/search/SearchTabs.tsx new file mode 100644 index 0000000..37302dd --- /dev/null +++ b/src/components/search/SearchTabs.tsx @@ -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.map((tab) => ( + + {t(tab.labelKey)} + + ))} + + + ); +} diff --git a/src/components/search/StandortBar.tsx b/src/components/search/StandortBar.tsx new file mode 100644 index 0000000..87ffa43 --- /dev/null +++ b/src/components/search/StandortBar.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/components/search/facets/BooleanFacet.tsx b/src/components/search/facets/BooleanFacet.tsx new file mode 100644 index 0000000..0434e96 --- /dev/null +++ b/src/components/search/facets/BooleanFacet.tsx @@ -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 ( +
+ +
+ {OPTIONS.map((opt) => { + const active = current === opt.key; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/search/facets/EnumFacet.tsx b/src/components/search/facets/EnumFacet.tsx new file mode 100644 index 0000000..0e1b5dc --- /dev/null +++ b/src/components/search/facets/EnumFacet.tsx @@ -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 ( +
+ +
+ {optionen.map((opt) => ( + + ))} +
+
+ ); +} diff --git a/src/components/search/facets/NumberRangeFacet.tsx b/src/components/search/facets/NumberRangeFacet.tsx new file mode 100644 index 0000000..63d5477 --- /dev/null +++ b/src/components/search/facets/NumberRangeFacet.tsx @@ -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 ( +
+ + emit(v[0] ?? min, v[1] ?? max)} + aria-label={facet.name} + /> +
+ emit(Number(e.target.value), hi)} + className="w-24" + /> + + emit(lo, Number(e.target.value))} + className="w-24" + /> +
+
+ ); +} diff --git a/src/components/search/results/BrigadeResultRow.tsx b/src/components/search/results/BrigadeResultRow.tsx new file mode 100644 index 0000000..c9bbf30 --- /dev/null +++ b/src/components/search/results/BrigadeResultRow.tsx @@ -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 ( +
  • + + {hit.name} + + {ortLine ? ( + {ortLine} + ) : null} +
  • + ); +} diff --git a/src/components/search/results/EquipmentResultRow.tsx b/src/components/search/results/EquipmentResultRow.tsx new file mode 100644 index 0000000..e62fe58 --- /dev/null +++ b/src/components/search/results/EquipmentResultRow.tsx @@ -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 ( +
  • + + {hit.name} + +
    + + {hit.eta ? ( + + ) : ( + + {t("search.eintreffzeitOffen")} + + )} +
    +
  • + ); +} diff --git a/src/components/search/results/ResultList.tsx b/src/components/search/results/ResultList.tsx new file mode 100644 index 0000000..e5bb9f6 --- /dev/null +++ b/src/components/search/results/ResultList.tsx @@ -0,0 +1,28 @@ +import { t } from "@/lib/i18n/de"; + +/** + * Generische Ergebnisliste mit Empty-State. `count` wird als Trefferzahl + * angezeigt; `children` sind die vorgerenderten `
  • `-Zeilen. + */ +export function ResultList({ + count, + children, +}: { + count: number; + children: React.ReactNode; +}) { + return ( +
    +

    + {count} {t("search.treffer")} +

    + {count === 0 ? ( +

    + {t("search.keineTreffer")} +

    + ) : ( +
      {children}
    + )} +
    + ); +} diff --git a/src/components/search/results/VehicleResultRow.tsx b/src/components/search/results/VehicleResultRow.tsx new file mode 100644 index 0000000..39362ed --- /dev/null +++ b/src/components/search/results/VehicleResultRow.tsx @@ -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 ( +
  • +
    + + {hit.name} + +

    + {hit.funkrufname ?? t("search.keinFunkrufname")} +

    +
    +
    + + {hit.eta ? ( + + ) : ( + + {t("search.eintreffzeitOffen")} + + )} +
    +
  • + ); +} diff --git a/src/components/search/useSearchParams.tsx b/src/components/search/useSearchParams.tsx new file mode 100644 index 0000000..5426123 --- /dev/null +++ b/src/components/search/useSearchParams.tsx @@ -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 | 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) => { + 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 }; +} diff --git a/src/lib/admin/codes.ts b/src/lib/admin/codes.ts new file mode 100644 index 0000000..d7a7962 --- /dev/null +++ b/src/lib/admin/codes.ts @@ -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; +} diff --git a/src/lib/db/indexes-trgm.sql b/src/lib/db/indexes-trgm.sql new file mode 100644 index 0000000..a0c102e --- /dev/null +++ b/src/lib/db/indexes-trgm.sql @@ -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); diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index a4a6fc3..6941f80 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -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", diff --git a/src/lib/search/__tests__/codes.test.ts b/src/lib/search/__tests__/codes.test.ts new file mode 100644 index 0000000..8fc9f89 --- /dev/null +++ b/src/lib/search/__tests__/codes.test.ts @@ -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); + }); +}); diff --git a/src/lib/search/__tests__/parse-params.test.ts b/src/lib/search/__tests__/parse-params.test.ts new file mode 100644 index 0000000..a9380a8 --- /dev/null +++ b/src/lib/search/__tests__/parse-params.test.ts @@ -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); + }); +}); diff --git a/src/lib/search/__tests__/query-vehicles.test.ts b/src/lib/search/__tests__/query-vehicles.test.ts new file mode 100644 index 0000000..7590794 --- /dev/null +++ b/src/lib/search/__tests__/query-vehicles.test.ts @@ -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) { + 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"); + }); +}); diff --git a/src/lib/search/facets.ts b/src/lib/search/facets.ts new file mode 100644 index 0000000..9a0651e --- /dev/null +++ b/src/lib/search/facets.ts @@ -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 { + 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> { + 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> { + 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(); + 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; +} diff --git a/src/lib/search/parse-params.ts b/src/lib/search/parse-params.ts new file mode 100644 index 0000000..58f5575 --- /dev/null +++ b/src/lib/search/parse-params.ts @@ -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.=...` = Filter (number `lo..hi`, enum CSV, boolean `ja/nein`). + */ +export function parseSearchParams( + raw: RawSearchParams, + facets: FacetDef[], +): SearchParams { + const facetById = new Map( + 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(); diff --git a/src/lib/search/query-brigades.ts b/src/lib/search/query-brigades.ts new file mode 100644 index 0000000..e8e5e73 --- /dev/null +++ b/src/lib/search/query-brigades.ts @@ -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 { + 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, + })); +} diff --git a/src/lib/search/query-equipment.ts b/src/lib/search/query-equipment.ts new file mode 100644 index 0000000..0af0489 --- /dev/null +++ b/src/lib/search/query-equipment.ts @@ -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 { + 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, + })); +} diff --git a/src/lib/search/query-vehicles.ts b/src/lib/search/query-vehicles.ts new file mode 100644 index 0000000..65dedbb --- /dev/null +++ b/src/lib/search/query-vehicles.ts @@ -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 { + 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, + })); +} diff --git a/src/lib/search/types.ts b/src/lib/search/types.ts new file mode 100644 index 0000000..105565c --- /dev/null +++ b/src/lib/search/types.ts @@ -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; +} diff --git a/tests/e2e/search.spec.ts b/tests/e2e/search.spec.ts new file mode 100644 index 0000000..5395f5f --- /dev/null +++ b/tests/e2e/search.spec.ts @@ -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.=…` 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(); + }); +});