Fix date validations, datepicker overflow, and QC components
This commit is contained in:
@@ -15,11 +15,11 @@ import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
|
||||
export interface JalaliDatePickerProps {
|
||||
value: string | null; // ISO date (یا YYYY-MM-DD با time اختیاری)
|
||||
value: string | null;
|
||||
onChange: (val: string | null) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
withTime?: boolean; // نمایش ساعت:دقیقه هم (پیشنهاد/پذیرش/رسید)
|
||||
withTime?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -42,20 +42,16 @@ function isoToJalali(iso: string | null): [number, number, number] | 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() };
|
||||
@@ -68,7 +64,6 @@ function timeOf(iso: string | null): { h: number; m: number } {
|
||||
return { h: d.getHours(), m: d.getMinutes() };
|
||||
}
|
||||
|
||||
// تقویم شمسی — پورت Jalali.buildPicker به React (با پشتیبانی ساعت:دقیقه)
|
||||
export function JalaliDatePicker({
|
||||
value,
|
||||
onChange,
|
||||
@@ -78,6 +73,7 @@ export function JalaliDatePicker({
|
||||
disabled = false,
|
||||
}: JalaliDatePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [alignment, setAlignment] = useState<"start" | "end">("start"); // تراز هوشمند
|
||||
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
||||
const initialTime = timeOf(value);
|
||||
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
|
||||
@@ -111,7 +107,6 @@ export function JalaliDatePicker({
|
||||
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));
|
||||
@@ -127,7 +122,18 @@ export function JalaliDatePicker({
|
||||
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false));
|
||||
|
||||
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
||||
// تابع تشخیص موقعیت فیلد و تنظیم تراز تقویم
|
||||
const handleOpen = () => {
|
||||
if (disabled) return;
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const isLeftHalf = rect.left + rect.width / 2 < window.innerWidth / 2;
|
||||
// اگر فیلد در نیمه چپ صفحه بود، تراز را از لبه چپ (end) میبندیم تا به سمت راست باز شود
|
||||
setAlignment(isLeftHalf ? "end" : "start");
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
function selectedLocalDate(): string | null {
|
||||
if (!value) return null;
|
||||
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
@@ -141,7 +147,7 @@ export function JalaliDatePicker({
|
||||
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 firstWd = (new Date(firstG[0], firstG[1] - 1, firstG[2]).getDay() + 1) % 7;
|
||||
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")}`;
|
||||
@@ -190,11 +196,8 @@ export function JalaliDatePicker({
|
||||
|
||||
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.length === 3) digits = digits.slice(-1);
|
||||
else if (digits.length > 3) digits = digits.slice(0, 2);
|
||||
if (!digits) {
|
||||
setHourText("");
|
||||
return;
|
||||
@@ -228,11 +231,8 @@ export function JalaliDatePicker({
|
||||
|
||||
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.length === 3) digits = digits.slice(-1);
|
||||
else if (digits.length > 3) digits = digits.slice(0, 2);
|
||||
if (!digits) {
|
||||
setMinuteText("");
|
||||
return;
|
||||
@@ -269,11 +269,7 @@ export function JalaliDatePicker({
|
||||
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);
|
||||
const val = withTime ? buildIso(jy, jm, jd, nh, nm) : buildDateOnly(jy, jm, jd);
|
||||
onChange(val);
|
||||
setText(
|
||||
withTime
|
||||
@@ -364,7 +360,7 @@ export function JalaliDatePicker({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<div ref={ref} className={cn("relative w-full", className)}>
|
||||
<input
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
@@ -372,43 +368,45 @@ export function JalaliDatePicker({
|
||||
value={text}
|
||||
placeholder={placeholder ?? "۱۴۰۳/۰۵/۱۲"}
|
||||
autoComplete="off"
|
||||
onFocus={() => !disabled && setOpen(true)}
|
||||
onClick={() => !disabled && setOpen(true)}
|
||||
onFocus={handleOpen}
|
||||
onClick={handleOpen}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onBlur={handleInputBlur}
|
||||
/>
|
||||
{open && !disabled && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-full z-30 mt-1 rounded-md border border-line bg-panel shadow-md",
|
||||
// حداکثر عرض = viewport (موبایل باریک سرریز نمیشود؛ دسکتاپ بیتغییر)
|
||||
"max-w-[calc(100vw-2rem)]",
|
||||
withTime ? "w-80 p-3" : "w-64 p-2",
|
||||
// پاپآپ مطلق که بر اساس کلاس تراز تنظیم میشود
|
||||
"absolute top-full z-[100] mt-1",
|
||||
alignment === "start" ? "start-0" : "end-0", // اگر فیلد چپ بود -> end-0 (تراز چپ) | اگر راست بود -> start-0 (تراز راست)
|
||||
"rounded-xl border border-line bg-panel shadow-2xl p-3.5",
|
||||
withTime ? "w-80" : "w-[280px]",
|
||||
"max-w-[calc(100vw-2rem)]" // حاشیه امنیت برای صفحه نمایش خیلی کوچک
|
||||
)}
|
||||
>
|
||||
{/* 1. Header: Month / Year Navigation */}
|
||||
<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")}>
|
||||
<div className="mb-2 flex items-center justify-between gap-1">
|
||||
<button type="button" className="cursor-pointer rounded p-1 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 type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("m+1")}>
|
||||
‹
|
||||
</button>
|
||||
<span className="text-[13px] font-bold">
|
||||
<span className="text-sm 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 type="button" className="cursor-pointer rounded p-1 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 type="button" className="cursor-pointer rounded p-1 text-sm hover:bg-slate-bg" onClick={() => nav("y-1")}>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 2. Day of Week Header & Days Grid */}
|
||||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||||
<div className="grid grid-cols-7 gap-1 text-center">
|
||||
{WEEKDAYS.map((w) => (
|
||||
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
||||
<div key={w} className="py-1 text-[11px] font-bold text-muted">
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
@@ -420,7 +418,7 @@ export function JalaliDatePicker({
|
||||
key={i}
|
||||
type="button"
|
||||
className={cn(
|
||||
"cursor-pointer rounded py-0.5 text-[12px] hover:bg-slate-bg max-md:py-1.5",
|
||||
"cursor-pointer rounded-lg py-2 text-[13px] hover:bg-slate-bg transition-colors",
|
||||
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",
|
||||
@@ -433,44 +431,43 @@ export function JalaliDatePicker({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */}
|
||||
{/* 3. Time Inputs Row */}
|
||||
{withTime && (
|
||||
<div className="mt-2 flex items-center justify-center gap-3 rounded-md border border-line/70 bg-slate-bg/40 py-1.5">
|
||||
<div className="mt-3 flex items-center justify-center gap-3 rounded-lg border border-line/70 bg-slate-bg/40 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
className="num-input h-7 w-12 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 max-md:h-9 max-md:w-14"
|
||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={minuteText}
|
||||
inputMode="numeric"
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={(e) => handleMinuteChange(e.target.value)}
|
||||
onBlur={handleMinuteBlur}
|
||||
/>
|
||||
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
||||
<span className="text-xs font-semibold text-muted">دقیقه</span>
|
||||
</div>
|
||||
<span className="font-bold text-muted">:</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
className="num-input h-7 w-12 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 max-md:h-9 max-md:w-14"
|
||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-sm tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
value={hourText}
|
||||
inputMode="numeric"
|
||||
onFocus={(e) => e.target.select()}
|
||||
onChange={(e) => handleHourChange(e.target.value)}
|
||||
onBlur={handleHourBlur}
|
||||
/>
|
||||
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
||||
<span className="text-xs font-semibold text-muted">ساعت</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* 4. Action Buttons Footer */}
|
||||
<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}>
|
||||
<div className="mt-3 flex justify-between border-t border-line pt-2">
|
||||
<button type="button" className="cursor-pointer rounded px-3 py-1 text-xs font-medium 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 type="button" className="cursor-pointer rounded px-3 py-1 text-xs font-medium text-muted hover:bg-slate-bg" onClick={clear}>
|
||||
پاک کردن
|
||||
</button>
|
||||
</div>
|
||||
@@ -478,4 +475,4 @@ export function JalaliDatePicker({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user