220 lines
7.3 KiB
TypeScript
220 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Calendar } from "@/components/ui/calendar";
|
|
import { CalendarIcon } from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
interface Props {
|
|
value?: string; // "dd/mm/yyyy hh:mm:ss"
|
|
onChange?: (value: string) => void;
|
|
className?: string;
|
|
}
|
|
|
|
export function DateTimePicker({ value, onChange, className }: Props) {
|
|
// --- store each part as string ---
|
|
const [local, setLocal] = React.useState({
|
|
d: "",
|
|
m: "",
|
|
y: "",
|
|
h: "",
|
|
min: "",
|
|
s: "",
|
|
});
|
|
const [open, setOpen] = React.useState(false);
|
|
|
|
// --- parse incoming value string ---
|
|
React.useEffect(() => {
|
|
if (!value) return;
|
|
const dt = dateGetter(value);
|
|
if (!dt) return;
|
|
setLocal({
|
|
d: String(dt.getDate()),
|
|
m: String(dt.getMonth() + 1),
|
|
y: String(dt.getFullYear()),
|
|
h: String(dt.getHours()),
|
|
min: String(dt.getMinutes()),
|
|
s: String(dt.getSeconds()),
|
|
});
|
|
}, [value]);
|
|
|
|
// --- emit combined string ---
|
|
const emitChange = React.useCallback(() => {
|
|
const dt = new Date(
|
|
Number(local.y) || 0,
|
|
Number(local.m) - 1 || 0,
|
|
Number(local.d) || 1,
|
|
Number(local.h) || 0,
|
|
Number(local.min) || 0,
|
|
Number(local.s) || 0
|
|
);
|
|
if (!isNaN(dt.getTime())) {
|
|
onChange?.(dateSetter(dt));
|
|
}
|
|
}, [local, onChange]);
|
|
|
|
const handleInput = (field: keyof typeof local, val: string) => {
|
|
if (/^\d*$/.test(val)) {
|
|
setLocal((prev) => ({ ...prev, [field]: val }));
|
|
}
|
|
};
|
|
|
|
const handleBlur = (field: keyof typeof local) => {
|
|
// Validate ranges
|
|
let val = Number(local[field]);
|
|
if (isNaN(val)) val = 0;
|
|
|
|
switch (field) {
|
|
case "d":
|
|
val = Math.max(1, Math.min(31, val));
|
|
break;
|
|
case "m":
|
|
val = Math.max(1, Math.min(12, val));
|
|
break;
|
|
case "y":
|
|
val = Math.max(1900, Math.min(2100, val));
|
|
break;
|
|
case "h":
|
|
val = Math.max(0, Math.min(23, val));
|
|
break;
|
|
case "min":
|
|
case "s":
|
|
val = Math.max(0, Math.min(59, val));
|
|
break;
|
|
}
|
|
|
|
setLocal((prev) => ({ ...prev, [field]: String(val) }));
|
|
emitChange();
|
|
};
|
|
|
|
const currentDate = dateGetter(dateSetter(new Date(
|
|
Number(local.y) || 0,
|
|
Number(local.m) - 1 || 0,
|
|
Number(local.d) || 1,
|
|
Number(local.h) || 0,
|
|
Number(local.min) || 0,
|
|
Number(local.s) || 0
|
|
)));
|
|
|
|
const handleCalendarSelect = (d: Date | undefined) => {
|
|
if (!d) return;
|
|
const updated = new Date(
|
|
d.getFullYear(),
|
|
d.getMonth(),
|
|
d.getDate(),
|
|
Number(local.h) || 0,
|
|
Number(local.min) || 0,
|
|
Number(local.s) || 0
|
|
);
|
|
const str = dateSetter(updated);
|
|
setLocal({
|
|
d: String(updated.getDate()),
|
|
m: String(updated.getMonth() + 1),
|
|
y: String(updated.getFullYear()),
|
|
h: String(updated.getHours()),
|
|
min: String(updated.getMinutes()),
|
|
s: String(updated.getSeconds()),
|
|
});
|
|
onChange?.(str);
|
|
};
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" className={cn("w-full justify-start", className)}>
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
{value || "Select date and time"}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
|
|
<PopoverContent className="w-auto p-4 space-y-3" align="start">
|
|
{/* Calendar */}
|
|
<Calendar mode="single" selected={currentDate ?? undefined} onSelect={handleCalendarSelect} />
|
|
|
|
{/* Inputs */}
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<Input
|
|
className="w-18"
|
|
min={1}
|
|
max={31}
|
|
placeholder="DD"
|
|
value={local.d}
|
|
onChange={(e) => handleInput("d", e.target.value)}
|
|
onBlur={() => handleBlur("d")}
|
|
/>
|
|
<Input
|
|
className="w-18"
|
|
min={1}
|
|
max={12}
|
|
placeholder="MM"
|
|
value={local.m}
|
|
onChange={(e) => handleInput("m", e.target.value)}
|
|
onBlur={() => handleBlur("m")}
|
|
/>
|
|
<Input
|
|
className="w-18"
|
|
min={1900}
|
|
max={2100}
|
|
placeholder="YYYY"
|
|
value={local.y}
|
|
onChange={(e) => handleInput("y", e.target.value)}
|
|
onBlur={() => handleBlur("y")}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-3 gap-2 mt-2">
|
|
<Input
|
|
className="w-18"
|
|
min={0}
|
|
max={23}
|
|
placeholder="HH"
|
|
value={local.h}
|
|
onChange={(e) => handleInput("h", e.target.value)}
|
|
onBlur={() => handleBlur("h")}
|
|
/>
|
|
<Input
|
|
className="w-18"
|
|
min={0}
|
|
max={59}
|
|
placeholder="MM"
|
|
value={local.min}
|
|
onChange={(e) => handleInput("min", e.target.value)}
|
|
onBlur={() => handleBlur("min")}
|
|
/>
|
|
<Input
|
|
className="w-18"
|
|
min={0}
|
|
max={59}
|
|
placeholder="SS"
|
|
value={local.s}
|
|
onChange={(e) => handleInput("s", e.target.value)}
|
|
onBlur={() => handleBlur("s")}
|
|
/>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|
|
|
|
// --- helpers for external usage ---
|
|
export const dateGetter = (str: string | undefined): Date | null => {
|
|
if (!str) return null;
|
|
const [d, m, yAndTime] = str.split("/");
|
|
if (!d || !m || !yAndTime) return null;
|
|
const [y, time] = yAndTime.split(" ");
|
|
if (!time) return null;
|
|
const [h, min, s] = time.split(":").map(Number);
|
|
return new Date(Number(y), Number(m) - 1, Number(d), h || 0, min || 0, s || 0);
|
|
};
|
|
|
|
export const dateSetter = (date: Date | null): string => {
|
|
if (!date) return "";
|
|
const fmt = (n: number) => String(n).padStart(2, "0");
|
|
return `${fmt(date.getDate())}/${fmt(date.getMonth() + 1)}/${date.getFullYear()} ${fmt(
|
|
date.getHours()
|
|
)}:${fmt(date.getMinutes())}:${fmt(date.getSeconds())}`;
|
|
};
|