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>
This commit is contained in:
hamed
2026-06-16 11:42:45 +03:30
co-authored by Claude Opus 4.8
parent 7949da9ba9
commit bdf453a3ab
5 changed files with 411 additions and 152 deletions
@@ -0,0 +1,107 @@
# انتخاب تاریخ تولد به‌صورت مرحله‌ای: سال ← ماه ← روز
## پروژه
`nobat724_front` (سایت عمومی، داشبورد کاربر).
> کاملاً frontend است. هیچ تغییری در بک‌اند یا قرارداد API لازم نیست؛ خروجی همان رشته‌ی جلالی فعلی است.
## زمینه
در داشبورد (`/dashboard` → اطلاعات کاربری)، فیلد «تاریخ تولد» از `components/common/JalaliDatePicker` استفاده می‌کند که فقط یک نمای **ماهانه** دارد: هدر فقط «ماه سال» را نشان می‌دهد و با فلش‌ها فقط ماه‌به‌ماه جلو/عقب می‌رود. برای انتخاب سال تولد (مثلاً ۱۳۷۰) کاربر باید ده‌ها بار فلش بزند — تجربه‌ی بدی برای تاریخ تولد است.
خواسته: تقویمِ تاریخ تولد باید مرحله‌ای باشد — **اول سال، بعد ماه، بعد روز**.
## مشکل / هدف
افزودن یک حالتِ انتخابِ مرحله‌ای (year → month → day) به `JalaliDatePicker` به‌صورت **opt-in با prop**، و فعال‌کردن آن فقط برای فیلد تاریخ تولد. سایر استفاده‌ها (انتخاب تاریخ نوبت و پنل) باید **دست‌نخورده** بمانند.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `components/common/JalaliDatePicker.js` | date picker جلالیِ مشترک — افزودن حالت مرحله‌ای |
| `components/dashboard/userAccount/detailUser/information/form/DateBirthday.js` | فیلد تاریخ تولد — فعال‌کردن حالت جدید با prop |
> **هشدار سازگاری:** `JalaliDatePicker` در این فایل‌ها هم استفاده می‌شود و نباید رفتارشان تغییر کند:
> `app/component/DatePickerContent.js`, `app/component/date/datePicker/date/FirstDatePicker.js`, `.../SecondDatePicker.js`, `components/layoutPanel/userDetail/Date.js`. پس حالت جدید باید پیش‌فرض **خاموش** باشد.
## وضعیت فعلی (کد واقعی)
`JalaliDatePicker.js` — فقط نمای ماهانه. هدر و ناوبری:
```jsx
const handlePrevMonth = () => setDisplayDate(moment(displayDate).subtract(1, "jMonth"));
const handleNextMonth = () => setDisplayDate(moment(displayDate).add(1, "jMonth"));
// ...
// هدر: فقط ماه و سال + دو فلش ماه
<Box sx={{ fontWeight: 600, fontSize: "14px" }}>
{MONTHS[displayDate.jMonth()]} {displayDate.jYear()}
</Box>
// ... سپس گرید روزها
```
`MONTHS` (۱۲ ماه جلالی) از قبل در همین فایل تعریف شده. `moment` = `moment-jalaali`؛ متدها: `jYear()`, `jMonth()`, `jDate()`, `moment.jDaysInMonth(y, m)`.
`DateBirthday.js`:
```jsx
<JalaliDatePicker
value={data}
placeholder="تاریخ تولد"
error={error}
onChange={(e) => changeData(convertToJalali(e._d), "birthday")}
textFieldProps={{ /* CalendarEdit icon */ }}
/>
```
## وظایف
### ۱. افزودن حالت مرحله‌ای به `JalaliDatePicker`
- یک prop جدید بگیر: `stepwise = false` (پیش‌فرض خاموش تا سازگاری حفظ شود).
- یک state داخلی `viewMode` با مقادیر `"year" | "month" | "day"`. وقتی Popover با `stepwise=true` باز می‌شود، `viewMode` روی `"year"` شروع شود؛ در غیر این صورت رفتار فعلی (مستقیم نمای روز) حفظ شود.
- **نمای سال:** یک گرید از سال‌ها (مثلاً ۱۲ سال در هر صفحه، با دو فلش برای صفحه‌ی قبل/بعدِ بازه‌ی سال‌ها). بازه‌ی منطقی برای تولد: از `currentJalaliYear - 100` تا `currentJalaliYear`. انتخاب سال → `displayDate` با آن سال به‌روز شود و `viewMode = "month"`.
- **نمای ماه:** گرید ۱۲ ماه از `MONTHS`. انتخاب ماه → `displayDate` با آن ماه، و `viewMode = "day"`.
- **نمای روز:** همان گرید روزهای فعلی (بدون تغییر). انتخاب روز → `onChange` صدا زده شود و Popover بسته شود (مثل حالا).
- در هدرِ حالت مرحله‌ای، عنوان قابل‌کلیک باشد تا کاربر بتواند به مرحله‌ی بالاتر برگردد: در نمای ماه، کلیک روی سال → برگشت به نمای سال؛ در نمای روز، کلیک روی «ماه سال» → برگشت به نمای ماه. (ناوبری رو به عقب)
- وقتی Popover بسته شد، `viewMode` برای بار بعد دوباره `"year"` شود.
طرح کلی:
```jsx
function JalaliDatePicker({ /* ...props */, stepwise = false }) {
const [viewMode, setViewMode] = useState(stepwise ? "year" : "day");
// باز شدن popover (stepwise): setViewMode("year")
// بسته شدن: setViewMode(stepwise ? "year" : "day")
// نمای سال
const renderYears = () => { /* گرید سال‌ها currentYear-100..currentYear */ };
// نمای ماه
const renderMonths = () => { /* گرید MONTHS، onClick → setDisplayDate(jMonth) + setViewMode("day") */ };
// نمای روز = همان رندر فعلی
// در بدنه‌ی Popover:
// stepwise ? (viewMode==="year" ? renderYears() : viewMode==="month" ? renderMonths() : renderDays())
// : renderDays()
}
```
- منطق `value`/`selectedDate`/`onChange` و خروجی (همان `moment` object که `DateBirthday` با `e._d` و `convertToJalali` مصرف می‌کند) **بدون تغییر** بماند.
### ۲. فعال‌کردن در `DateBirthday.js`
فقط prop اضافه کن:
```jsx
<JalaliDatePicker
value={data}
placeholder="تاریخ تولد"
error={error}
stepwise
onChange={(e) => changeData(convertToJalali(e._d), "birthday")}
textFieldProps={{ /* بدون تغییر */ }}
/>
```
## نکات مهم
- **سازگاری عقب‌رو:** `stepwise` پیش‌فرض `false`؛ هیچ‌یک از ۴ مصرف‌کننده‌ی دیگر (تاریخ نوبت/پنل) نباید رفتارشان عوض شود. این را با grep تأیید کن و دست به آن فایل‌ها نزن.
- خروجی `onChange` دقیقاً همان شیء `moment` فعلی باشد تا `convertToJalali(e._d)` در `DateBirthday` و مصرف بک‌اند (`birthday`/`date_of_birth`) نشکند.
- بازه‌ی سال‌ها برای تولد: `[امسالِ جلالی - 100, امسالِ جلالی]`، نزولی (سال‌های جدیدتر بالا) یا صعودی — هرکدام UX بهتری دارد؛ پیش‌فرض نزولی منطقی‌تر است.
- RTL، فونت Vazir، MUI v5 (`Box`/`Popover`/`IconButton` که از قبل import شده‌اند)؛ کتابخانه‌ی جدید اضافه نکن. از `MONTHS` و `moment-jalaali` موجود در همان فایل استفاده کن.
- اعداد فارسی/لاتین: مطابق رفتار فعلی فایل (`usePersianDigits: false`) نگه‌دار تا یک‌دست بماند.
- بعد از تغییر: `npm run build` بدون خطا؛ دستی تست کن — در داشبورد فیلد تاریخ تولد: کلیک → ابتدا گرید سال‌ها، انتخاب سال → ماه‌ها، انتخاب ماه → روزها، انتخاب روز → مقدار در فیلد ست شود؛ و یک فیلد تاریخِ نوبت را هم چک کن که هنوز مثل قبل (مستقیم نمای روز) کار می‌کند.
@@ -0,0 +1,57 @@
# رفع جهت تبدیل تاریخ تولد هنگام ایجاد پروفایل
## پروژه
`nobat724_front` (سایت عمومی، داشبورد).
> **Cross-repo:** وابسته به پرامپت بک‌اند `clinicpro/.claude/prompt/profile-birthday-jalali-persist.md` (که `birthday` را به‌صورت Unix ذخیره/برمی‌گرداند). آن **اول** اجرا شود.
## زمینه
تاریخ تولد در فرم داشبورد به‌صورت رشته‌ی جلالی از `JalaliDatePicker` می‌آید (`information.birthday = "jYYYY/jM/jD"`). تابع `changeDateType(data, toTimeStamp)` در `helper/index.js` بین جلالی‌رشته و Unix تبدیل می‌کند:
- `toTimeStamp=true``moment(birthday, "jYYYY/jMM/jDD").unix()` (جلالی → Unix، برای **ارسال**)
- `toTimeStamp=false``moment.unix(birthday).format("jYYYY/jMM/jDD")` (Unix → جلالی، برای **نمایش**)
بک‌اند `birthday` را به‌صورت **Unix** می‌پذیرد و برمی‌گرداند. مسیر به‌روزرسانی (`patchData`) درست `changeDateType(usedKays, true)` می‌زند. اما مسیر ایجاد (`postData`) به‌اشتباه `changeDateType(information, false)` می‌زند — یعنی هنگام **ارسال**، جهت تبدیل برعکس است و `moment.unix(جلالی-رشته)` یک مقدار بی‌معنا تولید می‌کند. در نتیجه هنگام ایجاد پروفایل، تاریخ تولد خراب ذخیره می‌شود.
## مشکل / هدف
در `postData` جهت `changeDateType` باید `true` باشد (مثل `patchData`)، تا `birthday` جلالی به Unix تبدیل و سپس ارسال شود.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `components/dashboard/userAccount/detailUser/information/ButtonSendData.js` | `postData` (ایجاد) — جهت اشتباه |
| `helper/index.js` | `changeDateType` (مرجع — تغییر نمی‌کند) |
## وضعیت فعلی (کد واقعی)
```js
const postData = () => {
request
.postUserProfile(changeDateType(information, false)) // ❌ باید true باشد (مثل patchData)
// ...
};
const patchData = () => {
const usedKays = removeAdditionalKeysDashboard(information);
request
.patchUserProfile(changeDateType(usedKays, true), information?.uuid, ...) // ✅ درست
// ...
};
```
## وظایف
### ۱. اصلاح جهت در `postData`
```js
request.postUserProfile(changeDateType(information, true))
```
- فقط `false``true`. بقیه‌ی منطق `postData` بدون تغییر.
## نکات مهم
- `changeDateType` و `patchData` تغییر نمی‌کنند؛ فقط یک آرگومان در `postData` اصلاح می‌شود.
- نمایش (load) از قبل با `changeDateType(changedData, false)` در `information/index.js` درست است و `birthday` که حالا بک‌اند به‌صورت Unix می‌دهد را به جلالی تبدیل می‌کند — تغییری لازم ندارد.
- بعد از تغییر: `npm run build` بدون خطا؛ تست دستی — ایجاد پروفایل جدید با تاریخ تولد → ذخیره و سپس نمایش درستِ همان تاریخ؛ و به‌روزرسانی هم همچنان درست کار کند.
+245 -151
View File
@@ -24,6 +24,20 @@ 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,
@@ -39,20 +53,20 @@ function JalaliDatePicker({
prevIcon = null,
dayLabelRender = null,
isFirst = false,
stepwise = false,
}) {
const [anchorEl, setAnchorEl] = useState(null);
const [selectedDate, setSelectedDate] = useState(
value ? moment(value) : moment()
);
const [displayDate, setDisplayDate] = useState(
value ? moment(value) : moment()
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 = moment(value);
const newDate = parseValue(value);
setSelectedDate(newDate);
setDisplayDate(newDate);
}
@@ -61,11 +75,15 @@ function JalaliDatePicker({
const handleClick = (event) => {
if (!disabled) {
setAnchorEl(event.currentTarget);
if (stepwise) {
setViewMode("year");
}
}
};
const handleClose = () => {
setAnchorEl(null);
if (stepwise) setViewMode("year");
};
const open = Boolean(anchorEl);
@@ -87,6 +105,16 @@ function JalaliDatePicker({
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"));
};
@@ -120,7 +148,211 @@ function JalaliDatePicker({
return selectedDate.format("jYYYY/jMM/jDD");
};
const days = getDaysInMonth();
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}>
@@ -158,150 +390,12 @@ function JalaliDatePicker({
},
}}
>
<Box
sx={{
width: 320,
p: 2,
bgcolor: "background.paper",
}}
>
{/* 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 sx={{ fontWeight: 600, fontSize: "14px" }}>
{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>
<Box sx={{ width: 320, p: 2, bgcolor: "background.paper" }}>
{stepwise && viewMode === "year"
? renderYearView()
: stepwise && viewMode === "month"
? renderMonthView()
: renderDayView()}
</Box>
</Popover>
</div>
@@ -27,7 +27,7 @@ function ButtonSendData({
const postData = () => {
request
.postUserProfile(changeDateType(information, false))
.postUserProfile(changeDateType(information, true))
.then((response) => {
if (response.uuid) {
fetch("/api/auth/refresh", {
@@ -11,6 +11,7 @@ function DateBirthday({ data, changeData, error }) {
value={data}
placeholder="تاریخ تولد"
error={error}
stepwise
onChange={(e) => {
changeData(convertToJalali(e._d), "birthday");
}}