Add professional Arabic date and time picker to booking form
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
This commit is contained in:
@@ -0,0 +1,224 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { DateTimePicker } from "@/components/date-time-picker";
|
||||||
|
|
||||||
const API = `${import.meta.env.BASE_URL}api`;
|
const API = `${import.meta.env.BASE_URL}api`;
|
||||||
|
|
||||||
@@ -303,24 +304,23 @@ export default function ProtocolRequestPage() {
|
|||||||
<Label htmlFor="startsAt">
|
<Label htmlFor="startsAt">
|
||||||
من <span className="text-rose-500">*</span>
|
من <span className="text-rose-500">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<DateTimePicker
|
||||||
id="startsAt"
|
id="startsAt"
|
||||||
type="datetime-local"
|
|
||||||
value={form.startsAt}
|
value={form.startsAt}
|
||||||
onChange={(e) => set("startsAt", e.target.value)}
|
onChange={(v) => set("startsAt", v)}
|
||||||
required
|
placeholder="اختر تاريخ ووقت البداية"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="endsAt">
|
<Label htmlFor="endsAt">
|
||||||
إلى <span className="text-rose-500">*</span>
|
إلى <span className="text-rose-500">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<DateTimePicker
|
||||||
id="endsAt"
|
id="endsAt"
|
||||||
type="datetime-local"
|
|
||||||
value={form.endsAt}
|
value={form.endsAt}
|
||||||
onChange={(e) => set("endsAt", e.target.value)}
|
onChange={(v) => set("endsAt", v)}
|
||||||
required
|
placeholder="اختر تاريخ ووقت النهاية"
|
||||||
|
minDate={form.startsAt ? new Date(form.startsAt) : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user