272 lines
11 KiB
TypeScript
272 lines
11 KiB
TypeScript
import { useMemo, useState } from "react";
|
||
import {
|
||
daysInMonth,
|
||
gregorianToJalali,
|
||
isoToJalaliStr,
|
||
jalaliStrToIso,
|
||
jalaliToGregorian,
|
||
MONTHS,
|
||
toFa,
|
||
WEEKDAYS,
|
||
} from "../lib/jalali";
|
||
import { faNum } from "../lib/formatters";
|
||
import { cn } from "../lib/utils";
|
||
import { useClickOutside } from "./useClickOutside";
|
||
|
||
export interface JalaliDatePickerProps {
|
||
value: string | null; // ISO date (با time اختیاری)
|
||
onChange: (iso: string | null) => void;
|
||
placeholder?: string;
|
||
className?: string;
|
||
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
|
||
}
|
||
|
||
function todayJalali(): [number, number, number] {
|
||
const now = new Date();
|
||
return gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate());
|
||
}
|
||
|
||
function isoToJalali(iso: string | null): [number, number, number] | null {
|
||
if (!iso) return null;
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return null;
|
||
return gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||
}
|
||
|
||
// ساخت ISO با زمان محلی (تاریخ شمسی + ساعت:دقیقه)
|
||
function buildIso(jy: number, jm: number, jd: number, h: number, m: number): string {
|
||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||
return new Date(gy, gm - 1, gd, h, m).toISOString();
|
||
}
|
||
|
||
function timeOf(iso: string | null): { h: number; m: number } {
|
||
if (!iso) return { h: 0, m: 0 };
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return { h: 0, m: 0 };
|
||
return { h: d.getHours(), m: d.getMinutes() };
|
||
}
|
||
|
||
// تقویم شمسی — پورت Jalali.buildPicker به React (با پشتیبانی ساعت:دقیقه)
|
||
export function JalaliDatePicker({
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
className,
|
||
withTime = false,
|
||
}: JalaliDatePickerProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
||
const [text, setText] = useState<string>(() => {
|
||
if (!value) return "";
|
||
const j = isoToJalaliStr(value);
|
||
if (!withTime) return j;
|
||
const d = new Date(value);
|
||
if (Number.isNaN(d.getTime())) return j;
|
||
const hh = String(d.getHours()).padStart(2, "0");
|
||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||
return `${j} ${faNum(hh + ":" + mm)}`;
|
||
});
|
||
|
||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||
|
||
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
||
function selectedLocalDate(): string | null {
|
||
if (!value) return null;
|
||
const d = new Date(value);
|
||
if (Number.isNaN(d.getTime())) return null;
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
}
|
||
const selectedIso = selectedLocalDate();
|
||
const time = timeOf(value);
|
||
|
||
const grid = useMemo(() => {
|
||
const [jy, jm] = curJ;
|
||
const firstG = jalaliToGregorian(jy, jm, 1);
|
||
const firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7; // شنبه = 0
|
||
const dim = daysInMonth(jy, jm);
|
||
const today = new Date();
|
||
const tIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||
const cells: { d: number; iso: string; isToday: boolean }[] = [];
|
||
for (let i = 0; i < firstWd; i++) cells.push({ d: 0, iso: "", isToday: false });
|
||
for (let d = 1; d <= dim; d++) {
|
||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, d);
|
||
const iso = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||
cells.push({ d, iso, isToday: iso === tIso });
|
||
}
|
||
return cells;
|
||
}, [curJ]);
|
||
|
||
const nav = (dir: string) => {
|
||
setCurJ(([jy, jm]) => {
|
||
let ny = jy;
|
||
let nm = jm;
|
||
if (dir === "m-1") nm = nm === 12 ? 1 : nm + 1;
|
||
if (dir === "m+1") nm = nm === 1 ? 12 : nm - 1;
|
||
if (dir === "y-1") ny += 1;
|
||
if (dir === "y+1") ny -= 1;
|
||
if (nm < 1) {
|
||
nm = 12;
|
||
ny -= 1;
|
||
}
|
||
if (nm > 12) {
|
||
nm = 1;
|
||
ny += 1;
|
||
}
|
||
return [ny, nm, 1];
|
||
});
|
||
};
|
||
|
||
const selectDay = (jy: number, jm: number, jd: number) => {
|
||
const isoWithTime = buildIso(jy, jm, jd, time.h, time.m);
|
||
onChange(isoWithTime);
|
||
setText(`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`);
|
||
setOpen(false);
|
||
};
|
||
|
||
const setTimePart = (part: "h" | "m", raw: string) => {
|
||
const n = parseInt(raw.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10);
|
||
if (Number.isNaN(n)) return;
|
||
const h = part === "h" ? Math.min(23, Math.max(0, n)) : time.h;
|
||
const m = part === "m" ? Math.min(59, Math.max(0, n)) : time.m;
|
||
const [jy, jm, jd] = curJ;
|
||
onChange(buildIso(jy, jm, jd, h, m));
|
||
// بهروزرسانی نمایش
|
||
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, h, m));
|
||
setText(`${j} ${faNum(String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"))}`);
|
||
};
|
||
|
||
const pickToday = () => {
|
||
const now = new Date();
|
||
const [jy, jm, jd] = todayJalali();
|
||
// بدون withTime فقط تاریخ امروز ثبت میشود — ساعت همیشه نیمهشب
|
||
const isoWithTime = withTime
|
||
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
|
||
: buildIso(jy, jm, jd, 0, 0);
|
||
onChange(isoWithTime);
|
||
setText(
|
||
withTime
|
||
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}`
|
||
: `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
|
||
);
|
||
setOpen(false);
|
||
};
|
||
|
||
const clear = () => {
|
||
onChange(null);
|
||
setText("");
|
||
setOpen(false);
|
||
};
|
||
|
||
const onInputChange = (v: string) => {
|
||
setText(v);
|
||
const iso = jalaliStrToIso(v);
|
||
if (iso) {
|
||
onChange(iso);
|
||
const j = isoToJalali(iso);
|
||
if (j) setCurJ([j[0], j[1], j[2]]);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div ref={ref} className={cn("relative", className)}>
|
||
<input
|
||
type="text"
|
||
className="flex h-9 w-full rounded-md border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||
value={text}
|
||
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
|
||
autoComplete="off"
|
||
onFocus={() => setOpen(true)}
|
||
onClick={() => setOpen(true)}
|
||
onChange={(e) => onInputChange(e.target.value)}
|
||
/>
|
||
{open && (
|
||
<div
|
||
className={cn(
|
||
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
|
||
withTime ? "w-80 p-3" : "w-64 p-2",
|
||
)}
|
||
>
|
||
<div className="mb-1.5 flex items-center justify-between gap-1">
|
||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
||
«
|
||
</button>
|
||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
|
||
‹
|
||
</button>
|
||
<span className="text-[13px] font-bold">
|
||
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
|
||
</span>
|
||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("m-1")}>
|
||
›
|
||
</button>
|
||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
|
||
»
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||
{WEEKDAYS.map((w) => (
|
||
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
||
{w}
|
||
</div>
|
||
))}
|
||
{grid.map((cell, i) =>
|
||
cell.d === 0 ? (
|
||
<div key={i} />
|
||
) : (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
className={cn(
|
||
"cursor-pointer rounded py-0.5 text-[12px] hover:bg-slate-bg",
|
||
cell.isToday && !selectedIso && "bg-blue-bg font-bold text-[#1d4ed8]",
|
||
cell.isToday && selectedIso && "font-bold text-[#1d4ed8]",
|
||
cell.iso === selectedIso && "bg-accent text-white hover:bg-accent-dark",
|
||
)}
|
||
onClick={() => selectDay(curJ[0], curJ[1], cell.d)}
|
||
>
|
||
{toFa(cell.d)}
|
||
</button>
|
||
),
|
||
)}
|
||
</div>
|
||
{withTime && (
|
||
<div className="mt-2 flex items-center justify-center gap-4 border-t border-line pt-2.5">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
||
<input
|
||
type="text"
|
||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||
value={faNum(String(time.h).padStart(2, "0"))}
|
||
inputMode="numeric"
|
||
maxLength={2}
|
||
onChange={(e) => setTimePart("h", e.target.value)}
|
||
/>
|
||
</div>
|
||
<span className="text-muted">:</span>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
||
<input
|
||
type="text"
|
||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||
value={faNum(String(time.m).padStart(2, "0"))}
|
||
inputMode="numeric"
|
||
maxLength={2}
|
||
onChange={(e) => setTimePart("m", e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="mt-2 flex justify-between border-t border-line pt-2">
|
||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
|
||
امروز
|
||
</button>
|
||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-muted hover:bg-slate-bg" onClick={clear}>
|
||
پاک کردن
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|