diff --git a/.claude/prompt/admin-jalali-date-input.md b/.claude/prompt/admin-jalali-date-input.md new file mode 100644 index 00000000..4168afe2 --- /dev/null +++ b/.claude/prompt/admin-jalali-date-input.md @@ -0,0 +1,156 @@ +# همه‌ی تقویم‌های پنل ادمین باید شمسی باشند (رفع تقویم میلادی PersianDateInput) + +## پروژه + +`clinicpro` (React 19 admin SPA داخل Symfony — `assets/admin/`) + +## زمینه + +کل محصول فارسی/RTL و تاریخ‌ها Jalali (شمسی) است. تقویم شمسی قبلاً نوشته شده: +`PersianCalendar.tsx` (پیکر ماه/سال/روز شمسی، خروجی `YYYY-MM-DD` میلادی) و روکش آن +`PersianDatePicker.tsx`. اما یک کامپوننت دیگر به‌نام `PersianDateInput.tsx` هنوز از +`` بومی مرورگر استفاده می‌کند که **تقویم میلادی** مرورگر را باز می‌کند. +هرجای پنل که این کامپوننت استفاده شده، کاربر تقویم میلادی می‌بیند — خلاف قاعده‌ی محصول. + +## مشکل / هدف + +**هدف:** هرجای پنل ادمین که از انتخاب تاریخ (تقویم) استفاده می‌شود، تقویم شمسی نمایش دهد. + +**تنها منبع میلادی در کل ادمین:** `assets/admin/components/ui/PersianDateInput.tsx` خط ۵۶ +(`type="date"`). با اصلاح همین یک فایل، همه‌ی call siteهای زیر یک‌جا شمسی می‌شوند +(بدون تغییر در آن‌ها، چون امضای Props ثابت می‌ماند). + +بررسی انجام‌شده: +- `PersianCalendar` / `PersianDatePicker` قبلاً شمسی‌اند — نیازی به بازنویسی ندارند. +- در کل `assets/admin` فقط **یک** `type="date"` وجود دارد (همین فایل). هیچ + `datetime-local` / `month` / `week` بومی دیگری نیست. +- تقویم ماهانه‌ی درون `DoctorDetailPage.tsx` و `PersianDateInput` محلیِ همان فایل + از قبل با `jalaali-js` شمسی‌اند — دست نزن. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `assets/admin/components/ui/PersianDateInput.tsx` | **تنها فایلی که تغییر می‌کند** — حذف input بومی، استفاده از PersianCalendar | +| `assets/admin/components/ui/PersianCalendar.tsx` | پیکر شمسی موجود (Props: `value`, `onChange`, `onClose`, `enableYearPicker`) — مرجع | +| `assets/admin/components/ui/PersianDatePicker.tsx` | الگوی درستِ استفاده از PersianCalendar (کپی همین ساختار) | +| `assets/admin/lib/utils.ts` → `formatDate()` | نمایش شمسیِ مقدار انتخاب‌شده (از قبل استفاده می‌شود) | + +### call siteهای فعلی PersianDateInput (نباید تغییر کنند — فقط برای اطمینان از سازگاری Props) + +``` +components/AppointmentActions.tsx:482,613 value/onChange +components/InsuranceModal.tsx:153,157 value/onChange/placeholder +components/NewAppointmentDrawer.tsx:257 value/onChange +components/PatientsFilterModal.tsx:84,86 value/onChange/placeholder +pages/AppointmentEditPage.tsx:150 value/onChange +pages/MyPaymentsPage.tsx:108,111 value/onChange/placeholder +pages/AppointmentCreatePage.tsx:254 value/onChange +pages/PatientDetailPage.tsx:373,510 value/onChange +pages/PatientRecordFormPage.tsx:132 value/onChange ← تاریخ تولد (نیاز به enableYearPicker) +pages/DoctorDetailPage.tsx:1687,1914,1918 این‌ها به PersianDateInput محلیِ همان فایل وصل‌اند، نه فایل مشترک — دست نزن +``` + +## وضعیت فعلی (کد مشکل‌دار) + +`assets/admin/components/ui/PersianDateInput.tsx` — لایه‌ی متنی شمسی است ولی پیکر بومی میلادی: + +```tsx +{/* hidden native input — opens picker on click */} + onChange(e.target.value)} + style={{ position: 'absolute', opacity: 0, pointerEvents: 'none', width: 1, height: 1, top: 0, left: 0 }} + tabIndex={-1} +/> +``` + +`Props` فعلی: `value, onChange, placeholder?, min?, max?, style?, className?`. +نکته: `min`/`max` در هیچ call siteی پاس داده نمی‌شوند (Prop مرده‌اند). + +## وظایف + +### ۱. بازنویسی `PersianDateInput.tsx` روی پیکر شمسی + +`` را حذف کن و همان الگوی `PersianDatePicker.tsx` را به کار ببر: +state باز/بسته + رندر شرطی ``. لایه‌ی متنی visible و `formatDate(value)` +و دکمه‌ی پاک‌کردن (X) و امضای Props فعلی را **حفظ کن** تا هیچ call siteی نشکند. + +```tsx +import { useState } from 'react'; +import { CalendarDaysIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { formatDate } from '../../lib/utils'; +import PersianCalendar from './PersianCalendar'; + +interface Props { + value: string; // YYYY-MM-DD میلادی + onChange: (v: string) => void; + placeholder?: string; + enableYearPicker?: boolean; // برای تاریخ تولد + style?: React.CSSProperties; + className?: string; +} + +export default function PersianDateInput({ value, onChange, placeholder = 'انتخاب تاریخ', enableYearPicker = false, style, className }: Props) { + const [open, setOpen] = useState(false); + return ( +
+
setOpen(o => !o)} style={{ /* همان استایل visible فعلی: height 36, border/surface, icon, placeholder color */ }}> + + {value ? formatDate(value) : placeholder} + {value && { e.stopPropagation(); onChange(''); }}>} +
+ {open && ( + { onChange(v); setOpen(false); }} + onClose={() => setOpen(false)} + enableYearPicker={enableYearPicker} + /> + )} +
+ ); +} +``` + +- `min`/`max` را حذف کن (استفاده‌ای ندارند). اگر خواستی امن‌تر باشی، نگه‌شان دار ولی + بدون اثر — ترجیح: حذف، مطابق قاعده‌ی «کد مرده ننویس». +- استایل لایه‌ی visible دقیقاً همان مقادیر فعلی فایل بماند (height 36، `--border`، + `--surface`، `minWidth 148`، fontSize 13، رنگ placeholder با `--text-3`). + +### ۲. فعال‌کردن انتخاب سال برای تاریخ تولد + +در `pages/PatientRecordFormPage.tsx:132` (فیلد `birth_date`) پراپ `enableYearPicker` را بده +تا کاربر بتواند سریع سال تولد را انتخاب کند: + +```tsx + form.setValue('birth_date', v)} enableYearPicker /> +``` + +(الگوی مشابه از قبل در `PatientRecordInfoForm.tsx:133` با `PersianDatePicker … enableYearPicker` هست.) + +### ۳. رفع هم‌پوشانی SOLID (اختیاری ولی توصیه‌شده) + +بعد از این تغییر، `PersianDateInput` و `PersianDatePicker` تقریباً یکی می‌شوند +(هر دو = لایه‌ی متنی + PersianCalendar). برای پرهیز از دوگانگی: +- گزینه‌ی ساده: `PersianDateInput` را یک روکش نازک روی `PersianDatePicker` کن + (`return `)، یا +- در همین تسک فقط رفتار را یکی کن و در کامنت بالای فایل اشاره کن که این دو باید + در آینده ادغام شوند. حذف کامل یکی از آن‌ها → نیازمند به‌روزرسانی همه‌ی importها است؛ + اگر انجامش می‌دهی، همه‌ی call siteها را هم اصلاح کن و tscرا سبز نگه دار. + +## نکات مهم + +- بعد از تغییر، حتماً تایپ‌چک: `ddev exec npx tsc --noEmit --project tsconfig.json` باید سبز شود. +- خروجی `PersianCalendar.onChange` همان `YYYY-MM-DD` میلادی است؛ قرارداد داده‌ی ارسالی + به API تغییر نمی‌کند — فقط UI تقویم شمسی می‌شود. رفتار submit/فیلترها نباید عوض شود. +- `DoctorDetailPage.tsx` یک `PersianDateInput` **محلیِ درون‌فایل** دارد (تعریف حدود خط ۱۷۷) + که با `jalaali-js` از قبل شمسی است و پراپ `minDate` دارد؛ به فایل مشترک ربطی ندارد — دست نزن. +- تست‌ها: اگر تستی برای `PersianDateInput` هست، آپدیت کن؛ در غیر این صورت یک تست کوتاه + Vitest اضافه کن که کلیک روی input، پیکر شمسی را باز می‌کند و انتخاب روز، `onChange` + با `YYYY-MM-DD` را صدا می‌زند (حالت موفق + پاک‌کردن مقدار). +- تغییرِ فقط-UI است؛ backend و `docs/api/*` نیاز به تغییر ندارند. diff --git a/assets/admin/components/ui/PersianDateInput.test.tsx b/assets/admin/components/ui/PersianDateInput.test.tsx new file mode 100644 index 00000000..f9556533 --- /dev/null +++ b/assets/admin/components/ui/PersianDateInput.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import PersianDateInput from '@/components/ui/PersianDateInput'; + +describe('PersianDateInput', () => { + it('placeholder را وقتی مقدار خالی است نشان می‌دهد', () => { + render(); + expect(screen.getByText('از تاریخ')).toBeInTheDocument(); + }); + + it('از تقویم شمسی استفاده می‌کند نه input بومی میلادی', () => { + const { container } = render(); + expect(container.querySelector('input[type="date"]')).toBeNull(); + }); + + it('با کلیک، تقویم شمسی باز می‌شود (نام ماه فارسی)', () => { + const { container } = render(); + fireEvent.click((container.firstChild as HTMLElement).firstChild as Element); + // 2024-03-25 میلادی = ۶ فروردین ۱۴۰۳ شمسی + expect(screen.getByText('فروردین')).toBeInTheDocument(); + }); + + it('انتخاب روز، onChange را با YYYY-MM-DD میلادی صدا می‌زند', () => { + const onChange = vi.fn(); + const { container } = render(); + fireEvent.click((container.firstChild as HTMLElement).firstChild as Element); + const target = Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent?.trim() === '۱۵', + ); + expect(target).toBeTruthy(); + fireEvent.click(target as Element); + expect(onChange).toHaveBeenCalledWith(expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/)); + }); + + it('دکمه پاک‌کردن، onChange با رشته خالی می‌فرستد', () => { + const onChange = vi.fn(); + const { container } = render(); + const svgs = container.querySelectorAll('svg'); + const clearIcon = svgs[svgs.length - 1]; + fireEvent.click(clearIcon.parentElement as Element); + expect(onChange).toHaveBeenCalledWith(''); + }); +}); diff --git a/assets/admin/components/ui/PersianDateInput.tsx b/assets/admin/components/ui/PersianDateInput.tsx index 10360233..6116745d 100644 --- a/assets/admin/components/ui/PersianDateInput.tsx +++ b/assets/admin/components/ui/PersianDateInput.tsx @@ -1,69 +1,17 @@ -import React, { useRef } from 'react'; -import { CalendarDaysIcon, XMarkIcon } from '@heroicons/react/24/outline'; -import { formatDate } from '../../lib/utils'; +import PersianDatePicker from './PersianDatePicker'; interface Props { - value: string; + value: string; // YYYY-MM-DD میلادی onChange: (v: string) => void; placeholder?: string; - min?: string; - max?: string; - style?: React.CSSProperties; - className?: string; + /** انتخاب سال→ماه→روز را فعال می‌کند (مناسب تاریخ تولد). */ + enableYearPicker?: boolean; } -export default function PersianDateInput({ value, onChange, placeholder = 'انتخاب تاریخ', min, max, style, className }: Props) { - const hiddenRef = useRef(null); - - const open = () => { - const el = hiddenRef.current; - if (!el) return; - if (typeof el.showPicker === 'function') { - try { el.showPicker(); } catch { el.focus(); } - } else { - el.focus(); - } - }; - - return ( -
- {/* visible text layer */} -
- - {value ? formatDate(value) : placeholder} - {value && ( - { e.stopPropagation(); onChange(''); }} - style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', color: 'var(--text-3)' }} - > - - - )} -
- - {/* hidden native input — opens picker on click */} - onChange(e.target.value)} - style={{ - position: 'absolute', opacity: 0, pointerEvents: 'none', - width: 1, height: 1, top: 0, left: 0, - }} - tabIndex={-1} - /> -
- ); +/** + * روکش نازک روی PersianDatePicker تا انتخاب تاریخ در همه‌ی صفحات با تقویم شمسی باشد. + * منبع واحد پیکر شمسی = PersianDatePicker/PersianCalendar. + */ +export default function PersianDateInput(props: Props) { + return ; } diff --git a/assets/admin/pages/PatientRecordFormPage.tsx b/assets/admin/pages/PatientRecordFormPage.tsx index 966c1796..414f86dc 100644 --- a/assets/admin/pages/PatientRecordFormPage.tsx +++ b/assets/admin/pages/PatientRecordFormPage.tsx @@ -129,7 +129,7 @@ export default function PatientRecordFormPage() {
- form.setValue('birth_date', v)} /> + form.setValue('birth_date', v)} enableYearPicker />