457eff44c4
Introduce a custom DateTimePicker component for improved date and time selection in the protocol request form, replacing native inputs with an Arabic-localized, RTL-compatible interface. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 8b01db58-6cde-464b-bd7d-46bd031e467f Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/OsTuUKE Replit-Helium-Checkpoint-Created: true
225 lines
6.1 KiB
TypeScript
225 lines
6.1 KiB
TypeScript
import * as React from "react";
|
||
import { CalendarClock } from "lucide-react";
|
||
import { cn } from "@/lib/utils";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Calendar } from "@/components/ui/calendar";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
|
||
const AR_LATN = "ar-u-nu-latn";
|
||
|
||
function pad(n: number): string {
|
||
return String(n).padStart(2, "0");
|
||
}
|
||
|
||
// Value contract: local wall-clock string "YYYY-MM-DDTHH:mm" (same shape a
|
||
// native datetime-local input produces) or "" when nothing is selected.
|
||
function parseValue(value: string): Date | undefined {
|
||
if (!value) return undefined;
|
||
const d = new Date(value);
|
||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||
}
|
||
|
||
function toValue(d: Date): string {
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(
|
||
d.getHours(),
|
||
)}:${pad(d.getMinutes())}`;
|
||
}
|
||
|
||
function to24(hour12: number, period: "am" | "pm"): number {
|
||
if (period === "am") return hour12 === 12 ? 0 : hour12;
|
||
return hour12 === 12 ? 12 : hour12 + 12;
|
||
}
|
||
|
||
const HOURS_12 = Array.from({ length: 12 }, (_, i) => i + 1);
|
||
const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5);
|
||
|
||
function TimeColumn({
|
||
values,
|
||
active,
|
||
onSelect,
|
||
format,
|
||
ariaLabel,
|
||
}: {
|
||
values: number[];
|
||
active: number;
|
||
onSelect: (v: number) => void;
|
||
format: (v: number) => string;
|
||
ariaLabel: string;
|
||
}) {
|
||
const activeRef = React.useRef<HTMLButtonElement>(null);
|
||
React.useEffect(() => {
|
||
activeRef.current?.scrollIntoView({ block: "center" });
|
||
}, [active]);
|
||
|
||
return (
|
||
<div
|
||
role="listbox"
|
||
aria-label={ariaLabel}
|
||
className="h-40 w-14 overflow-y-auto rounded-md border p-1"
|
||
>
|
||
<div className="flex flex-col gap-1">
|
||
{values.map((v) => {
|
||
const isActive = v === active;
|
||
return (
|
||
<Button
|
||
key={v}
|
||
ref={isActive ? activeRef : undefined}
|
||
type="button"
|
||
size="sm"
|
||
variant={isActive ? "default" : "ghost"}
|
||
className="h-8 w-full shrink-0"
|
||
onClick={() => onSelect(v)}
|
||
>
|
||
{format(v)}
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function DateTimePicker({
|
||
id,
|
||
value,
|
||
onChange,
|
||
placeholder = "اختر التاريخ والوقت",
|
||
disabled,
|
||
minDate,
|
||
}: {
|
||
id?: string;
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
disabled?: boolean;
|
||
minDate?: Date;
|
||
}) {
|
||
const [open, setOpen] = React.useState(false);
|
||
const selected = parseValue(value);
|
||
|
||
const hour12 = selected ? selected.getHours() % 12 || 12 : 9;
|
||
const minute = selected ? selected.getMinutes() : 0;
|
||
const period: "am" | "pm" = selected
|
||
? selected.getHours() < 12
|
||
? "am"
|
||
: "pm"
|
||
: "am";
|
||
|
||
const baseDate = (): Date => {
|
||
if (selected) return new Date(selected);
|
||
const d = minDate ? new Date(minDate) : new Date();
|
||
d.setHours(9, 0, 0, 0);
|
||
return d;
|
||
};
|
||
|
||
const commit = (next: Date) => onChange(toValue(next));
|
||
|
||
const handleDay = (day: Date | undefined) => {
|
||
if (!day) return;
|
||
const base = baseDate();
|
||
const next = new Date(day);
|
||
next.setHours(base.getHours(), base.getMinutes(), 0, 0);
|
||
commit(next);
|
||
};
|
||
|
||
const handleHour = (h: number) => {
|
||
const next = baseDate();
|
||
next.setHours(to24(h, period), next.getMinutes(), 0, 0);
|
||
commit(next);
|
||
};
|
||
|
||
const handleMinute = (m: number) => {
|
||
const next = baseDate();
|
||
next.setMinutes(m, 0, 0);
|
||
commit(next);
|
||
};
|
||
|
||
const handlePeriod = (p: "am" | "pm") => {
|
||
const next = baseDate();
|
||
next.setHours(to24(hour12, p), next.getMinutes(), 0, 0);
|
||
commit(next);
|
||
};
|
||
|
||
const label = selected
|
||
? new Intl.DateTimeFormat(AR_LATN, {
|
||
dateStyle: "medium",
|
||
timeStyle: "short",
|
||
}).format(selected)
|
||
: placeholder;
|
||
|
||
return (
|
||
<Popover open={open} onOpenChange={setOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
id={id}
|
||
type="button"
|
||
variant="outline"
|
||
disabled={disabled}
|
||
className={cn(
|
||
"w-full justify-between font-normal",
|
||
!selected && "text-muted-foreground",
|
||
)}
|
||
>
|
||
<span className="truncate">{label}</span>
|
||
<CalendarClock className="size-4 shrink-0 opacity-60" />
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-0" align="start">
|
||
<Calendar
|
||
mode="single"
|
||
selected={selected}
|
||
onSelect={handleDay}
|
||
defaultMonth={selected ?? minDate ?? new Date()}
|
||
disabled={minDate ? { before: minDate } : undefined}
|
||
autoFocus
|
||
/>
|
||
<div className="border-t p-3">
|
||
<div className="mb-2 text-center text-xs font-medium text-muted-foreground">
|
||
الوقت
|
||
</div>
|
||
<div className="flex items-stretch justify-center gap-2">
|
||
<TimeColumn
|
||
values={HOURS_12}
|
||
active={hour12}
|
||
onSelect={handleHour}
|
||
format={pad}
|
||
ariaLabel="الساعة"
|
||
/>
|
||
<div className="flex items-center text-muted-foreground">:</div>
|
||
<TimeColumn
|
||
values={MINUTES}
|
||
active={minute}
|
||
onSelect={handleMinute}
|
||
format={pad}
|
||
ariaLabel="الدقيقة"
|
||
/>
|
||
<div className="flex flex-col justify-center gap-1">
|
||
{(["am", "pm"] as const).map((p) => (
|
||
<Button
|
||
key={p}
|
||
type="button"
|
||
size="sm"
|
||
variant={period === p ? "default" : "outline"}
|
||
className="h-8 w-12"
|
||
onClick={() => handlePeriod(p)}
|
||
>
|
||
{p === "am" ? "ص" : "م"}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-end border-t p-2">
|
||
<Button type="button" size="sm" onClick={() => setOpen(false)}>
|
||
تم
|
||
</Button>
|
||
</div>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|