Fix date validations, datepicker overflow, and QC components

This commit is contained in:
Behrad
2026-10-14 15:24:11 +03:30
parent 707e40e840
commit ce1421d038
7 changed files with 667 additions and 1005 deletions
+49 -52
View File
@@ -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>
);
}
}