- JalaliDatePicker gains an opt-in `stepwise` mode (year → month → day): the year step is a scrollable list from the current Jalali year down to 1332, used by the birthday field. Default-off so appointment/panel pickers keep the month-grid behavior. Parse incoming Jalali string values safely (fixes NaN keys). - Birthday create path now converts Jalali→Unix on send (changeDateType true), matching the update path and the Unix-based backend contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
406 lines
14 KiB
JavaScript
406 lines
14 KiB
JavaScript
"use client";
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
|
import { TextField, Popover, IconButton, Box } from "@mui/material";
|
|
import moment from "moment-jalaali";
|
|
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
|
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
|
|
|
moment.loadPersian({ dialect: "persian-modern", usePersianDigits: false });
|
|
|
|
const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
|
const MONTHS = [
|
|
"فروردین",
|
|
"اردیبهشت",
|
|
"خرداد",
|
|
"تیر",
|
|
"مرداد",
|
|
"شهریور",
|
|
"مهر",
|
|
"آبان",
|
|
"آذر",
|
|
"دی",
|
|
"بهمن",
|
|
"اسفند",
|
|
];
|
|
|
|
const MIN_BIRTH_YEAR = 1332;
|
|
|
|
// Parse an incoming value that may be a Jalali string ("1370/5/15"), a Date,
|
|
// or a moment. Returns a valid moment, falling back to now() when invalid.
|
|
const parseValue = (value) => {
|
|
if (!value) return moment();
|
|
if (typeof value === "string") {
|
|
const m = moment(value, "jYYYY/jM/jD");
|
|
return m.isValid() ? m : moment();
|
|
}
|
|
const m = moment(value);
|
|
return m.isValid() ? m : moment();
|
|
};
|
|
|
|
function JalaliDatePicker({
|
|
value,
|
|
onChange,
|
|
label,
|
|
placeholder = "انتخاب تاریخ",
|
|
error = false,
|
|
helperText = "",
|
|
disabled = false,
|
|
className = "",
|
|
textFieldProps = {},
|
|
disabledDates = null,
|
|
nextIcon = null,
|
|
prevIcon = null,
|
|
dayLabelRender = null,
|
|
isFirst = false,
|
|
stepwise = false,
|
|
}) {
|
|
const [anchorEl, setAnchorEl] = useState(null);
|
|
const [selectedDate, setSelectedDate] = useState(() =>
|
|
value ? parseValue(value) : null
|
|
);
|
|
const [displayDate, setDisplayDate] = useState(() => parseValue(value));
|
|
const [viewMode, setViewMode] = useState(stepwise ? "year" : "day");
|
|
const nextIconRef = useRef(null);
|
|
const prevIconRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (value) {
|
|
const newDate = parseValue(value);
|
|
setSelectedDate(newDate);
|
|
setDisplayDate(newDate);
|
|
}
|
|
}, [value]);
|
|
|
|
const handleClick = (event) => {
|
|
if (!disabled) {
|
|
setAnchorEl(event.currentTarget);
|
|
if (stepwise) {
|
|
setViewMode("year");
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setAnchorEl(null);
|
|
if (stepwise) setViewMode("year");
|
|
};
|
|
|
|
const open = Boolean(anchorEl);
|
|
|
|
const handleDateClick = (day) => {
|
|
const newDate = moment(displayDate)
|
|
.jDate(day)
|
|
.jMonth(displayDate.jMonth())
|
|
.jYear(displayDate.jYear());
|
|
|
|
if (disabledDates && disabledDates(newDate)) {
|
|
return;
|
|
}
|
|
|
|
setSelectedDate(newDate);
|
|
if (onChange) {
|
|
onChange({ _d: newDate.toDate() });
|
|
}
|
|
handleClose();
|
|
};
|
|
|
|
const handleYearSelect = (year) => {
|
|
setDisplayDate(moment(displayDate).jYear(year));
|
|
setViewMode("month");
|
|
};
|
|
|
|
const handleMonthSelect = (month) => {
|
|
setDisplayDate(moment(displayDate).jMonth(month));
|
|
setViewMode("day");
|
|
};
|
|
|
|
const handlePrevMonth = () => {
|
|
setDisplayDate(moment(displayDate).subtract(1, "jMonth"));
|
|
};
|
|
|
|
const handleNextMonth = () => {
|
|
setDisplayDate(moment(displayDate).add(1, "jMonth"));
|
|
};
|
|
|
|
const getDaysInMonth = () => {
|
|
const year = displayDate.jYear();
|
|
const month = displayDate.jMonth();
|
|
const daysInMonth = moment.jDaysInMonth(year, month);
|
|
const firstDayOfMonth = moment([year, month, 1], "jYYYY/jMM/jDD").day();
|
|
|
|
const days = [];
|
|
const startOffset = (firstDayOfMonth + 1) % 7;
|
|
|
|
for (let i = 0; i < startOffset; i++) {
|
|
days.push(null);
|
|
}
|
|
|
|
for (let day = 1; day <= daysInMonth; day++) {
|
|
days.push(day);
|
|
}
|
|
|
|
return days;
|
|
};
|
|
|
|
const formatDisplayValue = () => {
|
|
if (!selectedDate) return "";
|
|
return selectedDate.format("jYYYY/jMM/jDD");
|
|
};
|
|
|
|
const renderYearView = () => {
|
|
const currentYear = moment().jYear();
|
|
// newest first, down to the minimum birth year
|
|
const years = Array.from(
|
|
{ length: currentYear - MIN_BIRTH_YEAR + 1 },
|
|
(_, i) => currentYear - i
|
|
);
|
|
return (
|
|
<>
|
|
<Box sx={{ fontWeight: 600, fontSize: "14px", textAlign: "center", mb: 2 }}>
|
|
انتخاب سال
|
|
</Box>
|
|
<Box
|
|
sx={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(3, 1fr)",
|
|
gap: 0.5,
|
|
maxHeight: 240,
|
|
overflowY: "auto",
|
|
}}
|
|
>
|
|
{years.map((year) => {
|
|
const isSelected = selectedDate && selectedDate.jYear() === year;
|
|
return (
|
|
<Box
|
|
key={year}
|
|
onClick={() => handleYearSelect(year)}
|
|
sx={{
|
|
textAlign: "center",
|
|
py: 1.5,
|
|
cursor: "pointer",
|
|
borderRadius: "4px",
|
|
fontSize: "14px",
|
|
bgcolor: isSelected ? "primary.main" : "transparent",
|
|
color: isSelected ? "primary.contrastText" : "text.primary",
|
|
"&:hover": { bgcolor: isSelected ? "primary.dark" : "action.hover" },
|
|
}}
|
|
>
|
|
{year}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const renderMonthView = () => (
|
|
<>
|
|
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", mb: 2 }}>
|
|
<Box
|
|
onClick={() => setViewMode("year")}
|
|
sx={{ fontWeight: 600, fontSize: "14px", cursor: "pointer" }}
|
|
>
|
|
{displayDate.jYear()}
|
|
</Box>
|
|
</Box>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 0.5 }}>
|
|
{MONTHS.map((monthName, idx) => {
|
|
const isSelected =
|
|
selectedDate &&
|
|
selectedDate.jYear() === displayDate.jYear() &&
|
|
selectedDate.jMonth() === idx;
|
|
return (
|
|
<Box
|
|
key={idx}
|
|
onClick={() => handleMonthSelect(idx)}
|
|
sx={{
|
|
textAlign: "center",
|
|
py: 1.5,
|
|
cursor: "pointer",
|
|
borderRadius: "4px",
|
|
fontSize: "14px",
|
|
bgcolor: isSelected ? "primary.main" : "transparent",
|
|
color: isSelected ? "primary.contrastText" : "text.primary",
|
|
"&:hover": { bgcolor: isSelected ? "primary.dark" : "action.hover" },
|
|
}}
|
|
>
|
|
{monthName}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
</>
|
|
);
|
|
|
|
const renderDayView = () => {
|
|
const days = getDaysInMonth();
|
|
return (
|
|
<>
|
|
{/* Header */}
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
|
|
{nextIcon ? (
|
|
<Box ref={nextIconRef} onClick={handleNextMonth} sx={{ cursor: "pointer" }}>
|
|
{nextIcon}
|
|
</Box>
|
|
) : (
|
|
<IconButton onClick={handleNextMonth} size="small">
|
|
<ChevronLeftIcon />
|
|
</IconButton>
|
|
)}
|
|
|
|
<Box
|
|
onClick={stepwise ? () => setViewMode("month") : undefined}
|
|
sx={{ fontWeight: 600, fontSize: "14px", cursor: stepwise ? "pointer" : "default" }}
|
|
>
|
|
{MONTHS[displayDate.jMonth()]} {displayDate.jYear()}
|
|
</Box>
|
|
|
|
{prevIcon ? (
|
|
<Box ref={prevIconRef} onClick={handlePrevMonth} sx={{ cursor: "pointer" }}>
|
|
{prevIcon}
|
|
</Box>
|
|
) : (
|
|
<IconButton onClick={handlePrevMonth} size="small">
|
|
<ChevronRightIcon />
|
|
</IconButton>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Weekday labels */}
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 0.5, mb: 1 }}>
|
|
{dayLabelRender ? (
|
|
dayLabelRender()
|
|
) : (
|
|
<>
|
|
{WEEKDAYS.map((day, index) => (
|
|
<Box
|
|
key={index}
|
|
sx={{
|
|
textAlign: "center",
|
|
fontSize: "12px",
|
|
fontWeight: 600,
|
|
color: "text.secondary",
|
|
py: 0.5,
|
|
}}
|
|
>
|
|
{day}
|
|
</Box>
|
|
))}
|
|
</>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Days grid */}
|
|
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 0.5 }}>
|
|
{days.map((day, index) => {
|
|
if (!day) {
|
|
return <Box key={index} />;
|
|
}
|
|
|
|
const dayDate = moment(displayDate)
|
|
.jDate(day)
|
|
.jMonth(displayDate.jMonth())
|
|
.jYear(displayDate.jYear());
|
|
|
|
const isSelected =
|
|
selectedDate &&
|
|
dayDate.jYear() === selectedDate.jYear() &&
|
|
dayDate.jMonth() === selectedDate.jMonth() &&
|
|
dayDate.jDate() === selectedDate.jDate();
|
|
|
|
const isToday =
|
|
dayDate.jYear() === moment().jYear() &&
|
|
dayDate.jMonth() === moment().jMonth() &&
|
|
dayDate.jDate() === moment().jDate();
|
|
|
|
const isDisabled = disabledDates && disabledDates(dayDate);
|
|
|
|
return (
|
|
<Box
|
|
key={index}
|
|
onClick={() => !isDisabled && handleDateClick(day)}
|
|
sx={{
|
|
textAlign: "center",
|
|
py: 1,
|
|
cursor: isDisabled ? "not-allowed" : "pointer",
|
|
borderRadius: "4px",
|
|
fontSize: "14px",
|
|
bgcolor: isSelected ? "primary.main" : "transparent",
|
|
color: isSelected
|
|
? "primary.contrastText"
|
|
: isDisabled
|
|
? "text.disabled"
|
|
: "text.primary",
|
|
border: isToday && !isSelected ? "1px solid" : "none",
|
|
borderColor: "primary.main",
|
|
opacity: isDisabled ? 0.3 : 1,
|
|
"&:hover": {
|
|
bgcolor: isDisabled
|
|
? "transparent"
|
|
: isSelected
|
|
? "primary.dark"
|
|
: "action.hover",
|
|
},
|
|
}}
|
|
>
|
|
{day}
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={className}>
|
|
<TextField
|
|
fullWidth
|
|
value={formatDisplayValue()}
|
|
onClick={handleClick}
|
|
placeholder={placeholder}
|
|
label={label}
|
|
error={error}
|
|
helperText={helperText}
|
|
disabled={disabled}
|
|
InputProps={{
|
|
readOnly: true,
|
|
}}
|
|
{...textFieldProps}
|
|
/>
|
|
<Popover
|
|
open={open}
|
|
anchorEl={anchorEl}
|
|
onClose={handleClose}
|
|
anchorOrigin={{
|
|
vertical: "bottom",
|
|
horizontal: "left",
|
|
}}
|
|
transformOrigin={{
|
|
vertical: "top",
|
|
horizontal: "left",
|
|
}}
|
|
sx={{
|
|
"& .MuiPaper-root": {
|
|
borderRadius: "8px",
|
|
boxShadow: "0px 4px 20px rgba(0, 0, 0, 0.1)",
|
|
overflow: "hidden",
|
|
},
|
|
}}
|
|
>
|
|
<Box sx={{ width: 320, p: 2, bgcolor: "background.paper" }}>
|
|
{stepwise && viewMode === "year"
|
|
? renderYearView()
|
|
: stepwise && viewMode === "month"
|
|
? renderMonthView()
|
|
: renderDayView()}
|
|
</Box>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default JalaliDatePicker;
|