From bdf453a3ab210d34c0ee1bc5af40cb7fd012029c Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 16 Jun 2026 11:42:45 +0330 Subject: [PATCH] feat(dashboard): stepwise Jalali birthday picker + correct send direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../prompt/birthday-picker-year-month-day.md | 107 +++++ .../profile-birthday-send-direction-fix.md | 57 +++ components/common/JalaliDatePicker.js | 396 +++++++++++------- .../detailUser/information/ButtonSendData.js | 2 +- .../information/form/DateBirthday.js | 1 + 5 files changed, 411 insertions(+), 152 deletions(-) create mode 100644 .claude/prompt/birthday-picker-year-month-day.md create mode 100644 .claude/prompt/profile-birthday-send-direction-fix.md diff --git a/.claude/prompt/birthday-picker-year-month-day.md b/.claude/prompt/birthday-picker-year-month-day.md new file mode 100644 index 0000000..285da8a --- /dev/null +++ b/.claude/prompt/birthday-picker-year-month-day.md @@ -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")); +// ... +// هدر: فقط ماه و سال + دو فلش ماه + + {MONTHS[displayDate.jMonth()]} {displayDate.jYear()} + +// ... سپس گرید روزها +``` +`MONTHS` (۱۲ ماه جلالی) از قبل در همین فایل تعریف شده. `moment` = `moment-jalaali`؛ متدها: `jYear()`, `jMonth()`, `jDate()`, `moment.jDaysInMonth(y, m)`. + +`DateBirthday.js`: +```jsx + 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 + 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` بدون خطا؛ دستی تست کن — در داشبورد فیلد تاریخ تولد: کلیک → ابتدا گرید سال‌ها، انتخاب سال → ماه‌ها، انتخاب ماه → روزها، انتخاب روز → مقدار در فیلد ست شود؛ و یک فیلد تاریخِ نوبت را هم چک کن که هنوز مثل قبل (مستقیم نمای روز) کار می‌کند. diff --git a/.claude/prompt/profile-birthday-send-direction-fix.md b/.claude/prompt/profile-birthday-send-direction-fix.md new file mode 100644 index 0000000..d7b14ed --- /dev/null +++ b/.claude/prompt/profile-birthday-send-direction-fix.md @@ -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` بدون خطا؛ تست دستی — ایجاد پروفایل جدید با تاریخ تولد → ذخیره و سپس نمایش درستِ همان تاریخ؛ و به‌روزرسانی هم همچنان درست کار کند. diff --git a/components/common/JalaliDatePicker.js b/components/common/JalaliDatePicker.js index 17faced..a5ef2dd 100644 --- a/components/common/JalaliDatePicker.js +++ b/components/common/JalaliDatePicker.js @@ -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 ( + <> + + انتخاب سال + + + {years.map((year) => { + const isSelected = selectedDate && selectedDate.jYear() === year; + return ( + 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} + + ); + })} + + + ); + }; + + const renderMonthView = () => ( + <> + + setViewMode("year")} + sx={{ fontWeight: 600, fontSize: "14px", cursor: "pointer" }} + > + {displayDate.jYear()} + + + + {MONTHS.map((monthName, idx) => { + const isSelected = + selectedDate && + selectedDate.jYear() === displayDate.jYear() && + selectedDate.jMonth() === idx; + return ( + 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} + + ); + })} + + + ); + + const renderDayView = () => { + const days = getDaysInMonth(); + return ( + <> + {/* Header */} + + {nextIcon ? ( + + {nextIcon} + + ) : ( + + + + )} + + setViewMode("month") : undefined} + sx={{ fontWeight: 600, fontSize: "14px", cursor: stepwise ? "pointer" : "default" }} + > + {MONTHS[displayDate.jMonth()]} {displayDate.jYear()} + + + {prevIcon ? ( + + {prevIcon} + + ) : ( + + + + )} + + + {/* Weekday labels */} + + {dayLabelRender ? ( + dayLabelRender() + ) : ( + <> + {WEEKDAYS.map((day, index) => ( + + {day} + + ))} + + )} + + + {/* Days grid */} + + {days.map((day, index) => { + if (!day) { + return ; + } + + 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 ( + !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} + + ); + })} + + + ); + }; return (
@@ -158,150 +390,12 @@ function JalaliDatePicker({ }, }} > - - {/* Header */} - - {nextIcon ? ( - - {nextIcon} - - ) : ( - - - - )} - - - {MONTHS[displayDate.jMonth()]} {displayDate.jYear()} - - - {prevIcon ? ( - - {prevIcon} - - ) : ( - - - - )} - - - {/* Weekday labels */} - - {dayLabelRender ? ( - dayLabelRender() - ) : ( - <> - {WEEKDAYS.map((day, index) => ( - - {day} - - ))} - - )} - - - {/* Days grid */} - - {days.map((day, index) => { - if (!day) { - return ; - } - - 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 ( - !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} - - ); - })} - + + {stepwise && viewMode === "year" + ? renderYearView() + : stepwise && viewMode === "month" + ? renderMonthView() + : renderDayView()}
diff --git a/components/dashboard/userAccount/detailUser/information/ButtonSendData.js b/components/dashboard/userAccount/detailUser/information/ButtonSendData.js index 77f1f0d..1b8cbb9 100644 --- a/components/dashboard/userAccount/detailUser/information/ButtonSendData.js +++ b/components/dashboard/userAccount/detailUser/information/ButtonSendData.js @@ -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", { diff --git a/components/dashboard/userAccount/detailUser/information/form/DateBirthday.js b/components/dashboard/userAccount/detailUser/information/form/DateBirthday.js index 53893c0..0565195 100644 --- a/components/dashboard/userAccount/detailUser/information/form/DateBirthday.js +++ b/components/dashboard/userAccount/detailUser/information/form/DateBirthday.js @@ -11,6 +11,7 @@ function DateBirthday({ data, changeData, error }) { value={data} placeholder="تاریخ تولد" error={error} + stepwise onChange={(e) => { changeData(convertToJalali(e._d), "birthday"); }}