Bladeren bron

RHL-025 feat(date-range): enhance SearchDateRangePicker with validation and improved date handling

Code_Uwe 2 weken geleden
bovenliggende
commit
341134c536

+ 276 - 225
components/search/form/SearchDateRangePicker.jsx

@@ -1,62 +1,62 @@
 "use client";
 
-import React from "react";
-import { CalendarRange, X } from "lucide-react";
+import * as React from "react";
+import { Calendar as CalendarIcon, X } from "lucide-react";
 
-import {
-	formatIsoDateDe,
-	formatIsoDateRangeLabelDe,
-	toDateFromIsoDateYmd,
-	toIsoDateYmdFromDate,
-	compareIsoDatesYmd,
-	normalizeIsoDateYmdOrNull,
-} from "@/lib/frontend/search/dateRange";
-
-import { buildDatePresets } from "@/lib/frontend/search/datePresets";
+import { cn } from "@/lib/utils";
 
 import { Calendar } from "@/components/ui/calendar";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
 import { Label } from "@/components/ui/label";
 import {
 	Popover,
 	PopoverContent,
 	PopoverTrigger,
 } from "@/components/ui/popover";
-import { cn } from "@/lib/utils";
 
-const FIELD = Object.freeze({
-	FROM: "from",
-	TO: "to",
-});
+import { isValidIsoDateYmd } from "@/lib/frontend/search/dateRange";
+import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation";
+import { buildDatePresets } from "@/lib/frontend/search/datePresets";
 
-function pickInitialActiveField(from, to) {
-	if (!from) return FIELD.FROM;
-	if (from && !to) return FIELD.TO;
-	return FIELD.FROM;
+function pad2(n) {
+	return String(n).padStart(2, "0");
 }
 
-function focusById(id) {
-	if (typeof document === "undefined") return;
-	const el = document.getElementById(id);
-	if (el && typeof el.focus === "function") el.focus();
+function dateToIsoYmd(date) {
+	if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
+
+	const y = date.getFullYear();
+	const m = pad2(date.getMonth() + 1);
+	const d = pad2(date.getDate());
+	return `${y}-${m}-${d}`;
 }
 
-function buildDisplayedRange(from, to) {
-	const fIso = normalizeIsoDateYmdOrNull(from);
-	const tIso = normalizeIsoDateYmdOrNull(to);
+function isoYmdToDate(iso) {
+	if (typeof iso !== "string") return null;
+	if (!isValidIsoDateYmd(iso)) return null;
 
-	const f = toDateFromIsoDateYmd(fIso);
-	const t = toDateFromIsoDateYmd(tIso);
+	const [y, m, d] = iso.split("-").map((x) => Number(x));
+	return new Date(y, m - 1, d);
+}
 
-	if (f && t && compareIsoDatesYmd(fIso, tIso) <= 0) {
-		return { from: f, to: t };
-	}
+const deDateFormatter = new Intl.DateTimeFormat("de-DE", {
+	day: "2-digit",
+	month: "2-digit",
+	year: "numeric",
+});
 
-	if (f) return { from: f, to: undefined };
-	if (t) return { from: t, to: undefined };
+function formatIsoToDe(iso) {
+	const d = isoYmdToDate(iso);
+	return d ? deDateFormatter.format(d) : "";
+}
 
-	return undefined;
+function getSummaryLabel(from, to) {
+	if (from && to) return `${formatIsoToDe(from)} – ${formatIsoToDe(to)}`;
+	if (from) return `ab ${formatIsoToDe(from)}`;
+	if (to) return `bis ${formatIsoToDe(to)}`;
+	return "Zeitraum auswählen";
 }
 
 export default function SearchDateRangePicker({
@@ -64,154 +64,189 @@ export default function SearchDateRangePicker({
 	to,
 	onDateRangeChange,
 	isSubmitting,
+	className,
 }) {
+	const disabled = Boolean(isSubmitting);
+
 	const [open, setOpen] = React.useState(false);
-	const [activeField, setActiveField] = React.useState(() =>
-		pickInitialActiveField(from, to)
-	);
+	const [activeField, setActiveField] = React.useState("from"); // "from" | "to"
 
-	const fromBtnId = React.useId();
-	const toBtnId = React.useId();
+	const fromRef = React.useRef(null);
+	const toRef = React.useRef(null);
 
-	const fromDate = React.useMemo(() => toDateFromIsoDateYmd(from), [from]);
-	const toDate = React.useMemo(() => toDateFromIsoDateYmd(to), [to]);
+	const fromDate = isoYmdToDate(from);
+	const toDate = isoYmdToDate(to);
+
+	const validation = getSearchDateRangeValidation(from ?? null, to ?? null);
+	const isRangeInvalid = validation?.code === "VALIDATION_SEARCH_RANGE";
 
 	const now = React.useMemo(() => new Date(), []);
 	const presets = React.useMemo(() => buildDatePresets({ now }), [now]);
 
-	// Calendar month anchor (controlled) so we can keep UX stable when switching fields.
-	const [month, setMonth] = React.useState(() => fromDate || toDate || now);
+	// Split into two rows (4 + 3):
+	// Row 1: short ranges (today/yesterday/last 7/last 30)
+	// Row 2: larger ranges (this month/last month/this year)
+	const presetsRow1 = presets.slice(0, 4);
+	const presetsRow2 = presets.slice(4);
+
+	const [month, setMonth] = React.useState(() => {
+		return fromDate || toDate || new Date();
+	});
 
-	// When popover opens: pick a sensible active field and month anchor.
 	React.useEffect(() => {
 		if (!open) return;
 
-		const nextActive = pickInitialActiveField(from, to);
-		setActiveField(nextActive);
-
-		const nextMonth =
-			nextActive === FIELD.FROM
-				? fromDate || toDate || now
-				: toDate || fromDate || now;
-
-		setMonth(nextMonth);
+		if (from && !to) {
+			setActiveField("to");
+			requestAnimationFrame(() => toRef.current?.focus?.());
+		} else {
+			setActiveField("from");
+			requestAnimationFrame(() => fromRef.current?.focus?.());
+		}
 
-		queueMicrotask(() => {
-			focusById(nextActive === FIELD.FROM ? fromBtnId : toBtnId);
-		});
+		setMonth(fromDate || toDate || new Date());
 		// eslint-disable-next-line react-hooks/exhaustive-deps
 	}, [open]);
 
-	const label = formatIsoDateRangeLabelDe({ from, to }) || "Zeitraum";
-	const fromLabel = formatIsoDateDe(from) || "Startdatum";
-	const toLabel = formatIsoDateDe(to) || "Enddatum";
-
-	const canClearBoth = Boolean((from || to) && !isSubmitting);
-	const canClearFrom = Boolean(from && !isSubmitting);
-	const canClearTo = Boolean(to && !isSubmitting);
-
-	const activeBlue =
-		"border-blue-600 bg-blue-50 dark:border-blue-900 dark:bg-blue-950";
+	function safeOnDateRangeChange(next) {
+		if (typeof onDateRangeChange !== "function") return;
 
-	const fromYear = 2000;
-	const toYear = now.getFullYear() + 1;
+		const hasFrom = Object.prototype.hasOwnProperty.call(next || {}, "from");
+		const hasTo = Object.prototype.hasOwnProperty.call(next || {}, "to");
 
-	// Show the selected range visually (works for presets too).
-	const displayedRange = React.useMemo(() => {
-		return buildDisplayedRange(from, to);
-	}, [from, to]);
+		onDateRangeChange({
+			from: hasFrom ? next.from : from ?? null,
+			to: hasTo ? next.to : to ?? null,
+		});
+	}
 
-	function commit(nextFrom, nextTo) {
-		if (typeof onDateRangeChange !== "function") return;
-		onDateRangeChange({ from: nextFrom, to: nextTo });
+	let calendarSelected = undefined;
+	let invalidInterval = null;
+
+	if (fromDate && toDate) {
+		if (isRangeInvalid) {
+			const min = fromDate < toDate ? fromDate : toDate;
+			const max = fromDate < toDate ? toDate : fromDate;
+			calendarSelected = { from: min, to: max };
+			invalidInterval = { from: min, to: max };
+		} else {
+			calendarSelected = { from: fromDate, to: toDate };
+		}
+	} else if (fromDate) {
+		calendarSelected = { from: fromDate, to: undefined };
+	} else if (toDate) {
+		calendarSelected = { from: toDate, to: toDate };
 	}
 
-	function setActive(field) {
-		setActiveField(field);
+	const calendarModifiers =
+		isRangeInvalid && invalidInterval
+			? {
+					invalid_range: invalidInterval,
+					invalid_range_edge: [fromDate, toDate].filter(Boolean),
+			  }
+			: undefined;
+
+	const calendarModifiersClassNames =
+		isRangeInvalid && invalidInterval
+			? {
+					invalid_range:
+						"bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive",
+					invalid_range_edge:
+						"!bg-destructive !text-white hover:!bg-destructive/90",
+			  }
+			: undefined;
+
+	function focusActiveField(nextField) {
+		if (nextField === "from") {
+			requestAnimationFrame(() => fromRef.current?.focus?.());
+		} else {
+			requestAnimationFrame(() => toRef.current?.focus?.());
+		}
+	}
 
-		const nextMonth =
-			field === FIELD.FROM ? fromDate || month : toDate || month;
+	function handlePickDay(...args) {
+		if (disabled) return;
 
-		if (nextMonth) setMonth(nextMonth);
+		let day = args[0];
+		let modifiers = args[1];
 
-		queueMicrotask(() => {
-			focusById(field === FIELD.FROM ? fromBtnId : toBtnId);
-		});
-	}
+		if (!(day instanceof Date) && args[1] instanceof Date) {
+			day = args[1];
+			modifiers = args[2];
+		}
 
-	function clearFrom() {
-		if (!canClearFrom) return;
+		if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
+		if (modifiers?.disabled) return;
 
-		commit(null, to ?? null);
+		const iso = dateToIsoYmd(day);
+		if (!iso) return;
 
-		setActiveField(FIELD.FROM);
-		setMonth(toDate || now);
+		if (activeField === "from") {
+			safeOnDateRangeChange({ from: iso });
+			setActiveField("to");
+			focusActiveField("to");
+			return;
+		}
 
-		queueMicrotask(() => focusById(fromBtnId));
+		safeOnDateRangeChange({ to: iso });
+		setActiveField("to");
+		focusActiveField("to");
 	}
 
-	function clearTo() {
-		if (!canClearTo) return;
+	function handleClearFrom(e) {
+		e?.preventDefault?.();
+		e?.stopPropagation?.();
+		if (disabled) return;
 
-		commit(from ?? null, null);
-
-		setActiveField(FIELD.TO);
-		setMonth(fromDate || now);
-
-		queueMicrotask(() => focusById(toBtnId));
+		safeOnDateRangeChange({ from: null });
+		setActiveField("from");
+		focusActiveField("from");
 	}
 
-	function clearBoth() {
-		if (!canClearBoth) return;
+	function handleClearTo(e) {
+		e?.preventDefault?.();
+		e?.stopPropagation?.();
+		if (disabled) return;
 
-		commit(null, null);
+		safeOnDateRangeChange({ to: null });
+		setActiveField("to");
+		focusActiveField("to");
+	}
 
-		setActiveField(FIELD.FROM);
-		setMonth(now);
+	function handleReset() {
+		if (disabled) return;
 
-		queueMicrotask(() => focusById(fromBtnId));
+		safeOnDateRangeChange({ from: null, to: null });
+		setActiveField("from");
+		setMonth(new Date());
+		focusActiveField("from");
 	}
 
 	function applyPreset(preset) {
+		if (disabled) return;
 		if (!preset?.from || !preset?.to) return;
 
-		commit(preset.from, preset.to);
-
-		// Keep popover open so the user immediately sees the range highlighted.
-		setActiveField(FIELD.TO);
+		safeOnDateRangeChange({ from: preset.from, to: preset.to });
 
-		const nextMonth = toDateFromIsoDateYmd(preset.from) || now;
+		const nextMonth =
+			isoYmdToDate(preset.from) || isoYmdToDate(preset.to) || new Date();
 		setMonth(nextMonth);
 
-		queueMicrotask(() => focusById(toBtnId));
+		setActiveField("to");
+		focusActiveField("to");
 	}
 
-	function handleDayClick(day) {
-		if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
-
-		const iso = toIsoDateYmdFromDate(day);
-		if (!iso) return;
-
-		if (activeField === FIELD.FROM) {
-			commit(iso, to ?? null);
-
-			// After selecting FROM, auto-switch to TO (fast range building).
-			setActiveField(FIELD.TO);
-			setMonth(day);
-
-			queueMicrotask(() => focusById(toBtnId));
-			return;
-		}
+	const summary = getSummaryLabel(from, to);
 
-		// TO
-		commit(from ?? null, iso);
-		setMonth(day);
+	const activeInputClass =
+		"border-blue-600 bg-blue-50 dark:border-blue-900 dark:bg-blue-950";
 
-		// Keep popover open; closing is user-controlled (click outside).
-	}
+	const calendarKey = `${from || ""}|${to || ""}|${
+		isRangeInvalid ? "inv" : "ok"
+	}`;
 
 	return (
-		<div className="grid gap-2">
+		<div className={cn("grid gap-2", className)}>
 			<Label>Zeitraum</Label>
 
 			<Popover open={open} onOpenChange={setOpen}>
@@ -219,69 +254,43 @@ export default function SearchDateRangePicker({
 					<Button
 						type="button"
 						variant="outline"
-						disabled={isSubmitting}
+						disabled={disabled}
+						className={cn(
+							"w-[240px] justify-between font-normal",
+							!from && !to ? "text-muted-foreground" : ""
+						)}
 						title="Zeitraum auswählen"
-						className="justify-between"
 					>
-						<span className="truncate">{label}</span>
-						<CalendarRange className="h-4 w-4 opacity-70" />
+						<span className="truncate">{summary}</span>
+						<CalendarIcon className="ml-2 h-4 w-4 opacity-70" />
 					</Button>
 				</PopoverTrigger>
 
-				{/* w-fit keeps the popover as compact as the 2-month calendar */}
+				{/* Fit to content (2 months). DayPicker supports multiple months via numberOfMonths. */}
 				<PopoverContent align="start" className="w-fit p-0">
-					<div className="p-3 space-y-3">
-						{/* Presets (wrap + compact) */}
-						<div className="space-y-2">
-							<p className="text-xs text-muted-foreground">Schnellwahl</p>
-
-							<div className="flex flex-wrap gap-2">
-								{presets.map((p) => (
-									<Badge key={p.key} variant="secondary" asChild>
-										<button
-											type="button"
-											className="cursor-pointer select-none hover:opacity-90"
-											disabled={isSubmitting}
-											onClick={() => applyPreset(p)}
-											title={`Schnellwahl: ${p.label}`}
-										>
-											{p.label}
-										</button>
-									</Badge>
-								))}
-							</div>
-						</div>
-
-						{/* Von / Bis controls */}
-						<div className="grid grid-cols-2 gap-3">
-							<div className="grid gap-2">
+					<div className="w-fit space-y-4 p-4">
+						<div className="grid grid-cols-2 gap-4">
+							<div className="space-y-1">
 								<Label>Von</Label>
 								<div className="relative">
-									<Button
-										id={fromBtnId}
-										type="button"
-										variant="outline"
-										size="sm"
-										disabled={isSubmitting}
-										onClick={() => setActive(FIELD.FROM)}
+									<Input
+										ref={fromRef}
+										readOnly
+										disabled={disabled}
+										value={from ? formatIsoToDe(from) : ""}
+										placeholder="TT.MM.JJJJ"
 										className={cn(
-											"w-full justify-between pr-10",
-											activeField === FIELD.FROM ? activeBlue : ""
+											"pr-8",
+											activeField === "from" ? activeInputClass : ""
 										)}
-										title="Startdatum auswählen"
-									>
-										<span className="truncate">{fromLabel}</span>
-									</Button>
-
-									{canClearFrom ? (
+										onFocus={() => setActiveField("from")}
+										onClick={() => setActiveField("from")}
+									/>
+									{from ? (
 										<button
 											type="button"
-											className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground"
-											onClick={(e) => {
-												e.preventDefault();
-												e.stopPropagation();
-												clearFrom();
-											}}
+											className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1 opacity-70 hover:opacity-100"
+											onClick={handleClearFrom}
 											aria-label="Startdatum löschen"
 											title="Startdatum löschen"
 										>
@@ -291,34 +300,27 @@ export default function SearchDateRangePicker({
 								</div>
 							</div>
 
-							<div className="grid gap-2">
+							<div className="space-y-1">
 								<Label>Bis</Label>
 								<div className="relative">
-									<Button
-										id={toBtnId}
-										type="button"
-										variant="outline"
-										size="sm"
-										disabled={isSubmitting}
-										onClick={() => setActive(FIELD.TO)}
+									<Input
+										ref={toRef}
+										readOnly
+										disabled={disabled}
+										value={to ? formatIsoToDe(to) : ""}
+										placeholder="TT.MM.JJJJ"
 										className={cn(
-											"w-full justify-between pr-10",
-											activeField === FIELD.TO ? activeBlue : ""
+											"pr-8",
+											activeField === "to" ? activeInputClass : ""
 										)}
-										title="Enddatum auswählen"
-									>
-										<span className="truncate">{toLabel}</span>
-									</Button>
-
-									{canClearTo ? (
+										onFocus={() => setActiveField("to")}
+										onClick={() => setActiveField("to")}
+									/>
+									{to ? (
 										<button
 											type="button"
-											className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-muted/60 hover:text-foreground"
-											onClick={(e) => {
-												e.preventDefault();
-												e.stopPropagation();
-												clearTo();
-											}}
+											className="absolute right-2 top-1/2 -translate-y-1/2 rounded-sm p-1 opacity-70 hover:opacity-100"
+											onClick={handleClearTo}
 											aria-label="Enddatum löschen"
 											title="Enddatum löschen"
 										>
@@ -329,38 +331,87 @@ export default function SearchDateRangePicker({
 							</div>
 						</div>
 
-						{/* Calendar: 2 months + range highlight */}
 						<Calendar
+							key={calendarKey}
 							mode="range"
-							captionLayout="dropdown"
-							fromYear={fromYear}
-							toYear={toYear}
 							numberOfMonths={2}
-							pagedNavigation
+							captionLayout="dropdown"
 							month={month}
 							onMonthChange={setMonth}
-							selected={displayedRange}
-							onDayClick={(day) => handleDayClick(day)}
-							initialFocus
+							selected={calendarSelected}
+							modifiers={calendarModifiers}
+							modifiersClassNames={calendarModifiersClassNames}
+							onDayClick={handlePickDay}
 						/>
 
-						<div className="flex justify-end gap-2 pt-1">
+						{/* Presets: two flex rows (no grid), always looks balanced */}
+						<div className="space-y-2">
+							<div className="text-sm text-muted-foreground">Schnellwahl</div>
+
+							<div className="flex flex-wrap gap-2">
+								{presetsRow1.map((p) => (
+									<Badge
+										key={p.key}
+										asChild
+										className={[
+											"bg-white text-black border-border",
+											"hover:bg-white/90",
+											"dark:bg-white dark:text-black",
+										].join(" ")}
+									>
+										<button
+											type="button"
+											className="cursor-pointer select-none disabled:opacity-60"
+											disabled={disabled}
+											onClick={() => applyPreset(p)}
+											title={p.label}
+										>
+											{p.label}
+										</button>
+									</Badge>
+								))}
+							</div>
+
+							<div className="flex flex-wrap gap-2">
+								{presetsRow2.map((p) => (
+									<Badge
+										key={p.key}
+										asChild
+										className={[
+											"bg-white text-black border-border",
+											"hover:bg-white/90",
+											"dark:bg-white dark:text-black",
+										].join(" ")}
+									>
+										<button
+											type="button"
+											className="cursor-pointer select-none disabled:opacity-60"
+											disabled={disabled}
+											onClick={() => applyPreset(p)}
+											title={p.label}
+										>
+											{p.label}
+										</button>
+									</Badge>
+								))}
+							</div>
+						</div>
+
+						<div className="flex items-center justify-between gap-4 pt-1">
+							<p className="text-xs text-muted-foreground">
+								Tipp: Für einen einzelnen Tag setzen Sie <b>Von</b> und{" "}
+								<b>Bis</b> auf dasselbe Datum.
+							</p>
+
 							<Button
 								type="button"
 								variant="outline"
-								size="sm"
-								disabled={!canClearBoth}
-								onClick={clearBoth}
-								title="Zeitraum entfernen"
+								disabled={disabled}
+								onClick={handleReset}
 							>
 								Zurücksetzen
 							</Button>
 						</div>
-
-						<div className="text-xs text-muted-foreground">
-							Tipp: Für einen einzelnen Tag setzen Sie <strong>Von</strong> und{" "}
-							<strong>Bis</strong> auf dasselbe Datum.
-						</div>
 					</div>
 				</PopoverContent>
 			</Popover>

+ 21 - 2
components/ui/calendar.jsx

@@ -181,10 +181,27 @@ function CalendarDayButton({ className, day, modifiers, ...props }) {
 		if (modifiers.focused) ref.current?.focus();
 	}, [modifiers.focused]);
 
+	const isInvalidRange = Boolean(modifiers?.invalid_range);
+	const isInvalidEdge = Boolean(modifiers?.invalid_range_edge);
+
+	// Apply invalid styling directly to the button (not only to the cell),
+	// otherwise the background can appear only "behind" the button.
+	const invalidRangeClasses = isInvalidRange
+		? [
+				"data-[range-middle=true]:!bg-destructive/20 data-[range-middle=true]:!text-white",
+				"data-[range-start=true]:!bg-destructive data-[range-start=true]:!text-white",
+				"data-[range-end=true]:!bg-destructive data-[range-end=true]:!text-white",
+				"data-[selected-single=true]:!bg-destructive data-[selected-single=true]:!text-white",
+		  ].join(" ")
+		: "";
+
+	const invalidEdgeClasses = isInvalidEdge
+		? "!bg-destructive !text-white hover:!bg-destructive/90"
+		: "";
+
 	return (
 		<Button
 			ref={ref}
-			// Prevent form submit when calendar is rendered inside a <form>.
 			type="button"
 			variant="ghost"
 			size="icon"
@@ -201,7 +218,9 @@ function CalendarDayButton({ className, day, modifiers, ...props }) {
 			className={cn(
 				"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
 				defaultClassNames.day,
-				className
+				className,
+				invalidRangeClasses,
+				invalidEdgeClasses
 			)}
 			{...props}
 		/>

+ 47 - 12
lib/frontend/search/searchApiInput.js

@@ -1,11 +1,24 @@
 import { SEARCH_SCOPE } from "@/lib/frontend/search/urlState";
-import { buildDateFilterValidationError } from "@/lib/frontend/search/dateFilterValidation";
-import { normalizeIsoDateYmdOrNull } from "@/lib/frontend/search/dateRange";
+import { ApiClientError } from "@/lib/frontend/apiClient";
+import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation";
 
 function isNonEmptyString(value) {
 	return typeof value === "string" && value.trim().length > 0;
 }
 
+function toTrimmedOrNull(value) {
+	return isNonEmptyString(value) ? value.trim() : null;
+}
+
+function buildValidationError(code, message, details) {
+	return new ApiClientError({
+		status: 400,
+		code,
+		message,
+		details,
+	});
+}
+
 /**
  * Build the apiClient.search(...) input from URL state + current user context.
  *
@@ -15,6 +28,22 @@ function isNonEmptyString(value) {
  *
  * UX policy for MULTI without branches:
  * - Treat it as "not ready" (input=null, error=null) instead of an error.
+ *
+ * @param {{
+ *   urlState: {
+ *     q: string|null,
+ *     scope: "single"|"multi"|"all",
+ *     branch: string|null,
+ *     branches: string[],
+ *     from: string|null,
+ *     to: string|null
+ *   },
+ *   routeBranch: string,
+ *   user: { role: string, branchId: string|null }|null,
+ *   cursor?: string|null,
+ *   limit?: number
+ * }} args
+ * @returns {{ input: any|null, error: any|null }}
  */
 export function buildSearchApiInput({
 	urlState,
@@ -28,19 +57,25 @@ export function buildSearchApiInput({
 	// UI policy (RHL-024): q is required to trigger a search.
 	if (!q) return { input: null, error: null };
 
-	// DRY: validate date filters via shared pure helper (RHL-025 UX + consistency).
-	const dateErr = buildDateFilterValidationError({
-		from: urlState?.from ?? null,
-		to: urlState?.to ?? null,
-	});
-	if (dateErr) return { input: null, error: dateErr };
+	// --- Date range validation (RHL-025) ------------------------------------
+	const from = toTrimmedOrNull(urlState?.from);
+	const to = toTrimmedOrNull(urlState?.to);
+
+	const dateValidation = getSearchDateRangeValidation(from, to);
+	if (dateValidation) {
+		return {
+			input: null,
+			error: buildValidationError(
+				dateValidation.code,
+				dateValidation.message,
+				dateValidation.details
+			),
+		};
+	}
 
+	// --- Build input ---------------------------------------------------------
 	const input = { q, limit };
 
-	// Normalize (trim + validity) before passing to apiClient.
-	const from = normalizeIsoDateYmdOrNull(urlState?.from);
-	const to = normalizeIsoDateYmdOrNull(urlState?.to);
-
 	if (from) input.from = from;
 	if (to) input.to = to;
 

+ 40 - 0
lib/frontend/search/searchDateValidation.js

@@ -0,0 +1,40 @@
+import {
+	isInvalidIsoDateRange,
+	isValidIsoDateYmd,
+} from "@/lib/frontend/search/dateRange";
+
+/**
+ * Zentralisierte Validation fuer Search-Datefilter (ISO YYYY-MM-DD).
+ * Gibt ein kleines Fehlerobjekt zurueck (oder null), ohne UI-Abhaengigkeiten.
+ *
+ * @param {string|null} from
+ * @param {string|null} to
+ * @returns {{code: string, message: string, details?: any} | null}
+ */
+export function getSearchDateRangeValidation(from, to) {
+	if (from && !isValidIsoDateYmd(from)) {
+		return {
+			code: "VALIDATION_SEARCH_DATE",
+			message: "Invalid from date",
+			details: { from },
+		};
+	}
+
+	if (to && !isValidIsoDateYmd(to)) {
+		return {
+			code: "VALIDATION_SEARCH_DATE",
+			message: "Invalid to date",
+			details: { to },
+		};
+	}
+
+	if (isInvalidIsoDateRange(from, to)) {
+		return {
+			code: "VALIDATION_SEARCH_RANGE",
+			message: "Invalid date range",
+			details: { from, to },
+		};
+	}
+
+	return null;
+}