import { useEffect, useMemo, useState } from "react"; import { daysInMonth, gregorianToJalali, isoToJalaliStr, jalaliStrToIso, jalaliToGregorian, MONTHS, toAsciiDigits, 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 (یا YYYY-MM-DD با time اختیاری) onChange: (val: string | null) => void; placeholder?: string; className?: string; withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید) disabled?: 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 m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/); if (m) { const gy = parseInt(m[1], 10); const gm = parseInt(m[2], 10); const gd = parseInt(m[3], 10); return gregorianToJalali(gy, gm, gd); } 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(); } // ساخت تاریخ استاندارد YYYY-MM-DD بدون مؤلفه زمان function buildDateOnly(jy: number, jm: number, jd: number): string { const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd); return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; } // وقتی هنوز زمان مشخصی ست نشده، ساعت/دقیقه‌ی فعلی سیستم پیش‌فرض است // (نه ۰۰:۰۰) — دکمه‌ی «امروز» و انتخاب روز، همین زمان را نگه می‌دارند. function nowTime(): { h: number; m: number } { const d = new Date(); return { h: d.getHours(), m: d.getMinutes() }; } function timeOf(iso: string | null): { h: number; m: number } { if (!iso) return nowTime(); const d = new Date(iso); if (Number.isNaN(d.getTime())) return nowTime(); return { h: d.getHours(), m: d.getMinutes() }; } // تقویم شمسی — پورت Jalali.buildPicker به React (با پشتیبانی ساعت:دقیقه) export function JalaliDatePicker({ value, onChange, placeholder, className, withTime = false, disabled = false, }: JalaliDatePickerProps) { const [open, setOpen] = useState(false); const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali()); const initialTime = timeOf(value); const [hourText, setHourText] = useState(() => faNum(String(initialTime.h).padStart(2, "0"))); const [minuteText, setMinuteText] = useState(() => faNum(String(initialTime.m).padStart(2, "0"))); const [text, setText] = useState(() => { 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)}`; }); useEffect(() => { if (!value) { setText(""); const now = nowTime(); setHourText(faNum(String(now.h).padStart(2, "0"))); setMinuteText(faNum(String(now.m).padStart(2, "0"))); return; } const j = isoToJalaliStr(value); const t = timeOf(value); if (!withTime) { setText(j); } else { const hh = String(t.h).padStart(2, "0"); const mm = String(t.m).padStart(2, "0"); setText(`${j} ${faNum(hh + ":" + mm)}`); // Only update local input texts if numeric values differ, to avoid wiping in-progress typing const curH = parseInt(toAsciiDigits(hourText) || "0", 10); if (curH !== t.h) { setHourText(faNum(hh)); } const curM = parseInt(toAsciiDigits(minuteText) || "0", 10); if (curM !== t.m) { setMinuteText(faNum(mm)); } } const jParts = isoToJalali(value); if (jParts) setCurJ(jParts); }, [value, withTime]); const ref = useClickOutside(() => setOpen(false)); // تاریخ انتخاب‌شده به صورت محلی (نه UTC) برای مقایسه با سلول‌های تقویم function selectedLocalDate(): string | null { if (!value) return null; const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/); if (m) return `${m[1]}-${m[2]}-${m[3]}`; 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 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 currentH = parseInt(toAsciiDigits(hourText) || "0", 10); const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); const validH = Math.min(23, Math.max(0, currentH)); const validM = Math.min(59, Math.max(0, currentM)); const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd); onChange(val); setText( `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(validH).padStart(2, "0") + ":" + String(validM).padStart(2, "0")) : ""}` ); setOpen(false); }; const handleHourChange = (raw: string) => { let digits = toAsciiDigits(raw).replace(/\D/g, ""); if (digits.length === 3) { digits = digits.slice(-1); } else if (digits.length > 3) { digits = digits.slice(0, 2); } if (!digits) { setHourText(""); return; } const n = parseInt(digits, 10); const clamped = Math.min(23, Math.max(0, n)); const textToShow = digits.length === 2 && n > 23 ? String(clamped) : digits; setHourText(faNum(textToShow)); const [jy, jm, jd] = curJ; const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); const validM = Math.min(59, Math.max(0, currentM)); onChange(buildIso(jy, jm, jd, clamped, validM)); const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM)); setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`); }; const handleHourBlur = () => { const digits = toAsciiDigits(hourText).replace(/\D/g, ""); const n = digits ? parseInt(digits, 10) : 0; const clamped = Math.min(23, Math.max(0, n)); setHourText(faNum(String(clamped).padStart(2, "0"))); const [jy, jm, jd] = curJ; const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10); const validM = Math.min(59, Math.max(0, currentM)); onChange(buildIso(jy, jm, jd, clamped, validM)); const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM)); setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`); }; const handleMinuteChange = (raw: string) => { let digits = toAsciiDigits(raw).replace(/\D/g, ""); if (digits.length === 3) { digits = digits.slice(-1); } else if (digits.length > 3) { digits = digits.slice(0, 2); } if (!digits) { setMinuteText(""); return; } const n = parseInt(digits, 10); const clamped = Math.min(59, Math.max(0, n)); const textToShow = digits.length === 2 && n > 59 ? String(clamped) : digits; setMinuteText(faNum(textToShow)); const [jy, jm, jd] = curJ; const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); const validH = Math.min(23, Math.max(0, currentH)); onChange(buildIso(jy, jm, jd, validH, clamped)); const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped)); setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`); }; const handleMinuteBlur = () => { const digits = toAsciiDigits(minuteText).replace(/\D/g, ""); const n = digits ? parseInt(digits, 10) : 0; const clamped = Math.min(59, Math.max(0, n)); setMinuteText(faNum(String(clamped).padStart(2, "0"))); const [jy, jm, jd] = curJ; const currentH = parseInt(toAsciiDigits(hourText) || "0", 10); const validH = Math.min(23, Math.max(0, currentH)); onChange(buildIso(jy, jm, jd, validH, clamped)); const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped)); setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`); }; const pickToday = () => { const now = new Date(); const [jy, jm, jd] = todayJalali(); const nh = now.getHours(); const nm = now.getMinutes(); setHourText(faNum(String(nh).padStart(2, "0"))); setMinuteText(faNum(String(nm).padStart(2, "0"))); const val = withTime ? buildIso(jy, jm, jd, nh, nm) : buildDateOnly(jy, jm, jd); onChange(val); setText( withTime ? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(nh).padStart(2, "0") + ":" + String(nm).padStart(2, "0"))}` : `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`, ); setOpen(false); }; const clear = () => { onChange(null); setText(""); const now = nowTime(); setHourText(faNum(String(now.h).padStart(2, "0"))); setMinuteText(faNum(String(now.m).padStart(2, "0"))); setOpen(false); }; const onInputChange = (v: string) => { setText(v); if (!v.trim()) { onChange(null); return; } const iso = jalaliStrToIso(v); if (iso) { if (!withTime) { const nums = String(v) .split(/[/\-.]/) .map((x) => parseInt(toAsciiDigits(x), 10)); if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) { const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]); onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`); } else { onChange(iso.slice(0, 10)); } } else { onChange(iso); } const j = isoToJalali(iso); if (j) setCurJ([j[0], j[1], j[2]]); } }; const handleInputBlur = () => { if (!text.trim()) { onChange(null); return; } const iso = jalaliStrToIso(text); if (iso) { if (!withTime) { const nums = String(text) .split(/[/\-.]/) .map((x) => parseInt(toAsciiDigits(x), 10)); if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) { const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]); const dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`; onChange(dOnly); setText(isoToJalaliStr(dOnly)); } else { onChange(iso.slice(0, 10)); setText(isoToJalaliStr(iso)); } } else { onChange(iso); const j = isoToJalaliStr(iso); const d = new Date(iso); if (!Number.isNaN(d.getTime())) { const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); setText(`${j} ${faNum(hh + ":" + mm)}`); } } } else if (value) { const j = isoToJalaliStr(value); if (!withTime) { setText(j); } else { const d = new Date(value); if (!Number.isNaN(d.getTime())) { const hh = String(d.getHours()).padStart(2, "0"); const mm = String(d.getMinutes()).padStart(2, "0"); setText(`${j} ${faNum(hh + ":" + mm)}`); } } } }; return (
!disabled && setOpen(true)} onClick={() => !disabled && setOpen(true)} onChange={(e) => onInputChange(e.target.value)} onBlur={handleInputBlur} /> {open && !disabled && (
{/* 1. Header: Month / Year Navigation */}
{MONTHS[curJ[1] - 1]} {toFa(curJ[0])}
{/* 2. Day of Week Header & Days Grid */}
{WEEKDAYS.map((w) => (
{w}
))} {grid.map((cell, i) => cell.d === 0 ? (
) : ( ), )}
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */} {withTime && (
e.target.select()} onChange={(e) => handleMinuteChange(e.target.value)} onBlur={handleMinuteBlur} /> دقیقه
:
e.target.select()} onChange={(e) => handleHourChange(e.target.value)} onBlur={handleHourBlur} /> ساعت
)} {/* 4. Action Buttons Footer */}
)}
); }