|
@@ -16,48 +16,7 @@ import {
|
|
|
PopoverTrigger,
|
|
PopoverTrigger,
|
|
|
} from "@/components/ui/popover";
|
|
} from "@/components/ui/popover";
|
|
|
|
|
|
|
|
-import { isValidIsoDateYmd } from "@/lib/frontend/search/dateRange";
|
|
|
|
|
-import { getSearchDateRangeValidation } from "@/lib/frontend/search/searchDateValidation";
|
|
|
|
|
-import { buildDatePresets } from "@/lib/frontend/search/datePresets";
|
|
|
|
|
-
|
|
|
|
|
-function pad2(n) {
|
|
|
|
|
- return String(n).padStart(2, "0");
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-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 isoYmdToDate(iso) {
|
|
|
|
|
- if (typeof iso !== "string") return null;
|
|
|
|
|
- if (!isValidIsoDateYmd(iso)) return null;
|
|
|
|
|
-
|
|
|
|
|
- const [y, m, d] = iso.split("-").map((x) => Number(x));
|
|
|
|
|
- return new Date(y, m - 1, d);
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const deDateFormatter = new Intl.DateTimeFormat("de-DE", {
|
|
|
|
|
- day: "2-digit",
|
|
|
|
|
- month: "2-digit",
|
|
|
|
|
- year: "numeric",
|
|
|
|
|
-});
|
|
|
|
|
-
|
|
|
|
|
-function formatIsoToDe(iso) {
|
|
|
|
|
- const d = isoYmdToDate(iso);
|
|
|
|
|
- return d ? deDateFormatter.format(d) : "";
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-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";
|
|
|
|
|
-}
|
|
|
|
|
|
|
+import { useSearchDateRangePicker } from "@/lib/frontend/search/useSearchDateRangePicker";
|
|
|
|
|
|
|
|
export default function SearchDateRangePicker({
|
|
export default function SearchDateRangePicker({
|
|
|
from,
|
|
from,
|
|
@@ -66,185 +25,40 @@ export default function SearchDateRangePicker({
|
|
|
isSubmitting,
|
|
isSubmitting,
|
|
|
className,
|
|
className,
|
|
|
}) {
|
|
}) {
|
|
|
- const disabled = Boolean(isSubmitting);
|
|
|
|
|
-
|
|
|
|
|
- const [open, setOpen] = React.useState(false);
|
|
|
|
|
- const [activeField, setActiveField] = React.useState("from"); // "from" | "to"
|
|
|
|
|
-
|
|
|
|
|
- const fromRef = React.useRef(null);
|
|
|
|
|
- const toRef = React.useRef(null);
|
|
|
|
|
-
|
|
|
|
|
- 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]);
|
|
|
|
|
-
|
|
|
|
|
- // 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();
|
|
|
|
|
|
|
+ const {
|
|
|
|
|
+ disabled,
|
|
|
|
|
+ open,
|
|
|
|
|
+ setOpen,
|
|
|
|
|
+ activeField,
|
|
|
|
|
+ setActiveField,
|
|
|
|
|
+ fromRef,
|
|
|
|
|
+ toRef,
|
|
|
|
|
+ month,
|
|
|
|
|
+ setMonth,
|
|
|
|
|
+ summary,
|
|
|
|
|
+ fromDisplay,
|
|
|
|
|
+ toDisplay,
|
|
|
|
|
+ presetsRow1,
|
|
|
|
|
+ presetsRow2,
|
|
|
|
|
+ calendarKey,
|
|
|
|
|
+ calendarSelected,
|
|
|
|
|
+ calendarModifiers,
|
|
|
|
|
+ calendarModifiersClassNames,
|
|
|
|
|
+ handlePickDay,
|
|
|
|
|
+ handleClearFrom,
|
|
|
|
|
+ handleClearTo,
|
|
|
|
|
+ handleReset,
|
|
|
|
|
+ applyPreset,
|
|
|
|
|
+ } = useSearchDateRangePicker({
|
|
|
|
|
+ from,
|
|
|
|
|
+ to,
|
|
|
|
|
+ onDateRangeChange,
|
|
|
|
|
+ isSubmitting,
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- React.useEffect(() => {
|
|
|
|
|
- if (!open) return;
|
|
|
|
|
-
|
|
|
|
|
- if (from && !to) {
|
|
|
|
|
- setActiveField("to");
|
|
|
|
|
- requestAnimationFrame(() => toRef.current?.focus?.());
|
|
|
|
|
- } else {
|
|
|
|
|
- setActiveField("from");
|
|
|
|
|
- requestAnimationFrame(() => fromRef.current?.focus?.());
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- setMonth(fromDate || toDate || new Date());
|
|
|
|
|
- // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
- }, [open]);
|
|
|
|
|
-
|
|
|
|
|
- function safeOnDateRangeChange(next) {
|
|
|
|
|
- if (typeof onDateRangeChange !== "function") return;
|
|
|
|
|
-
|
|
|
|
|
- const hasFrom = Object.prototype.hasOwnProperty.call(next || {}, "from");
|
|
|
|
|
- const hasTo = Object.prototype.hasOwnProperty.call(next || {}, "to");
|
|
|
|
|
-
|
|
|
|
|
- onDateRangeChange({
|
|
|
|
|
- from: hasFrom ? next.from : from ?? null,
|
|
|
|
|
- to: hasTo ? next.to : to ?? null,
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- 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 };
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- 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?.());
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function handlePickDay(...args) {
|
|
|
|
|
- if (disabled) return;
|
|
|
|
|
-
|
|
|
|
|
- let day = args[0];
|
|
|
|
|
- let modifiers = args[1];
|
|
|
|
|
-
|
|
|
|
|
- if (!(day instanceof Date) && args[1] instanceof Date) {
|
|
|
|
|
- day = args[1];
|
|
|
|
|
- modifiers = args[2];
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (!(day instanceof Date) || Number.isNaN(day.getTime())) return;
|
|
|
|
|
- if (modifiers?.disabled) return;
|
|
|
|
|
-
|
|
|
|
|
- const iso = dateToIsoYmd(day);
|
|
|
|
|
- if (!iso) return;
|
|
|
|
|
-
|
|
|
|
|
- if (activeField === "from") {
|
|
|
|
|
- safeOnDateRangeChange({ from: iso });
|
|
|
|
|
- setActiveField("to");
|
|
|
|
|
- focusActiveField("to");
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- safeOnDateRangeChange({ to: iso });
|
|
|
|
|
- setActiveField("to");
|
|
|
|
|
- focusActiveField("to");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function handleClearFrom(e) {
|
|
|
|
|
- e?.preventDefault?.();
|
|
|
|
|
- e?.stopPropagation?.();
|
|
|
|
|
- if (disabled) return;
|
|
|
|
|
-
|
|
|
|
|
- safeOnDateRangeChange({ from: null });
|
|
|
|
|
- setActiveField("from");
|
|
|
|
|
- focusActiveField("from");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function handleClearTo(e) {
|
|
|
|
|
- e?.preventDefault?.();
|
|
|
|
|
- e?.stopPropagation?.();
|
|
|
|
|
- if (disabled) return;
|
|
|
|
|
-
|
|
|
|
|
- safeOnDateRangeChange({ to: null });
|
|
|
|
|
- setActiveField("to");
|
|
|
|
|
- focusActiveField("to");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function handleReset() {
|
|
|
|
|
- if (disabled) return;
|
|
|
|
|
-
|
|
|
|
|
- safeOnDateRangeChange({ from: null, to: null });
|
|
|
|
|
- setActiveField("from");
|
|
|
|
|
- setMonth(new Date());
|
|
|
|
|
- focusActiveField("from");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function applyPreset(preset) {
|
|
|
|
|
- if (disabled) return;
|
|
|
|
|
- if (!preset?.from || !preset?.to) return;
|
|
|
|
|
-
|
|
|
|
|
- safeOnDateRangeChange({ from: preset.from, to: preset.to });
|
|
|
|
|
-
|
|
|
|
|
- const nextMonth =
|
|
|
|
|
- isoYmdToDate(preset.from) || isoYmdToDate(preset.to) || new Date();
|
|
|
|
|
- setMonth(nextMonth);
|
|
|
|
|
-
|
|
|
|
|
- setActiveField("to");
|
|
|
|
|
- focusActiveField("to");
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const summary = getSummaryLabel(from, to);
|
|
|
|
|
-
|
|
|
|
|
const activeInputClass =
|
|
const activeInputClass =
|
|
|
"border-blue-600 bg-blue-50 dark:border-blue-900 dark:bg-blue-950";
|
|
"border-blue-600 bg-blue-50 dark:border-blue-900 dark:bg-blue-950";
|
|
|
|
|
|
|
|
- const calendarKey = `${from || ""}|${to || ""}|${
|
|
|
|
|
- isRangeInvalid ? "inv" : "ok"
|
|
|
|
|
- }`;
|
|
|
|
|
-
|
|
|
|
|
return (
|
|
return (
|
|
|
<div className={cn("grid gap-2", className)}>
|
|
<div className={cn("grid gap-2", className)}>
|
|
|
<Label>Zeitraum</Label>
|
|
<Label>Zeitraum</Label>
|
|
@@ -266,7 +80,6 @@ export default function SearchDateRangePicker({
|
|
|
</Button>
|
|
</Button>
|
|
|
</PopoverTrigger>
|
|
</PopoverTrigger>
|
|
|
|
|
|
|
|
- {/* Fit to content (2 months). DayPicker supports multiple months via numberOfMonths. */}
|
|
|
|
|
<PopoverContent align="start" className="w-fit p-0">
|
|
<PopoverContent align="start" className="w-fit p-0">
|
|
|
<div className="w-fit space-y-4 p-4">
|
|
<div className="w-fit space-y-4 p-4">
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
@@ -277,7 +90,7 @@ export default function SearchDateRangePicker({
|
|
|
ref={fromRef}
|
|
ref={fromRef}
|
|
|
readOnly
|
|
readOnly
|
|
|
disabled={disabled}
|
|
disabled={disabled}
|
|
|
- value={from ? formatIsoToDe(from) : ""}
|
|
|
|
|
|
|
+ value={fromDisplay}
|
|
|
placeholder="TT.MM.JJJJ"
|
|
placeholder="TT.MM.JJJJ"
|
|
|
className={cn(
|
|
className={cn(
|
|
|
"pr-8",
|
|
"pr-8",
|
|
@@ -307,7 +120,7 @@ export default function SearchDateRangePicker({
|
|
|
ref={toRef}
|
|
ref={toRef}
|
|
|
readOnly
|
|
readOnly
|
|
|
disabled={disabled}
|
|
disabled={disabled}
|
|
|
- value={to ? formatIsoToDe(to) : ""}
|
|
|
|
|
|
|
+ value={toDisplay}
|
|
|
placeholder="TT.MM.JJJJ"
|
|
placeholder="TT.MM.JJJJ"
|
|
|
className={cn(
|
|
className={cn(
|
|
|
"pr-8",
|
|
"pr-8",
|
|
@@ -344,7 +157,6 @@ export default function SearchDateRangePicker({
|
|
|
onDayClick={handlePickDay}
|
|
onDayClick={handlePickDay}
|
|
|
/>
|
|
/>
|
|
|
|
|
|
|
|
- {/* Presets: two flex rows (no grid), always looks balanced */}
|
|
|
|
|
<div className="space-y-2">
|
|
<div className="space-y-2">
|
|
|
<div className="text-sm text-muted-foreground">Schnellwahl</div>
|
|
<div className="text-sm text-muted-foreground">Schnellwahl</div>
|
|
|
|
|
|
|
@@ -389,7 +201,7 @@ export default function SearchDateRangePicker({
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
- <div className="flex justify-end pt-1">
|
|
|
|
|
|
|
+ <div className="flex justify-end">
|
|
|
<Button
|
|
<Button
|
|
|
type="button"
|
|
type="button"
|
|
|
variant="outline"
|
|
variant="outline"
|