Files
nobat724_front/components/common/JalaliDatePicker.js
T
hamedandClaude Opus 4.8 bdf453a3ab feat(dashboard): stepwise Jalali birthday picker + correct send direction
- 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>
2026-06-16 11:42:45 +03:30

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;