feat(date-input): refactor PersianDateInput to use PersianDatePicker for Jalali calendar support
This commit is contained in:
@@ -0,0 +1,156 @@
|
|||||||
|
# همهی تقویمهای پنل ادمین باید شمسی باشند (رفع تقویم میلادی PersianDateInput)
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`clinicpro` (React 19 admin SPA داخل Symfony — `assets/admin/`)
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
کل محصول فارسی/RTL و تاریخها Jalali (شمسی) است. تقویم شمسی قبلاً نوشته شده:
|
||||||
|
`PersianCalendar.tsx` (پیکر ماه/سال/روز شمسی، خروجی `YYYY-MM-DD` میلادی) و روکش آن
|
||||||
|
`PersianDatePicker.tsx`. اما یک کامپوننت دیگر بهنام `PersianDateInput.tsx` هنوز از
|
||||||
|
`<input type="date">` بومی مرورگر استفاده میکند که **تقویم میلادی** مرورگر را باز میکند.
|
||||||
|
هرجای پنل که این کامپوننت استفاده شده، کاربر تقویم میلادی میبیند — خلاف قاعدهی محصول.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
**هدف:** هرجای پنل ادمین که از انتخاب تاریخ (تقویم) استفاده میشود، تقویم شمسی نمایش دهد.
|
||||||
|
|
||||||
|
**تنها منبع میلادی در کل ادمین:** `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 */}
|
||||||
|
<input
|
||||||
|
ref={hiddenRef}
|
||||||
|
type="date" // ← تقویم میلادی مرورگر
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
onChange={e => 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` روی پیکر شمسی
|
||||||
|
|
||||||
|
`<input type="date">` را حذف کن و همان الگوی `PersianDatePicker.tsx` را به کار ببر:
|
||||||
|
state باز/بسته + رندر شرطی `<PersianCalendar>`. لایهی متنی 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 (
|
||||||
|
<div style={{ position: 'relative', display: 'inline-block', ...style }} className={className}>
|
||||||
|
<div onClick={() => setOpen(o => !o)} style={{ /* همان استایل visible فعلی: height 36, border/surface, icon, placeholder color */ }}>
|
||||||
|
<CalendarDaysIcon /* … */ />
|
||||||
|
<span style={{ flex: 1 }}>{value ? formatDate(value) : placeholder}</span>
|
||||||
|
{value && <span onClick={e => { e.stopPropagation(); onChange(''); }}><XMarkIcon /* … */ /></span>}
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<PersianCalendar
|
||||||
|
value={value}
|
||||||
|
onChange={v => { onChange(v); setOpen(false); }}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
enableYearPicker={enableYearPicker}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `min`/`max` را حذف کن (استفادهای ندارند). اگر خواستی امنتر باشی، نگهشان دار ولی
|
||||||
|
بدون اثر — ترجیح: حذف، مطابق قاعدهی «کد مرده ننویس».
|
||||||
|
- استایل لایهی visible دقیقاً همان مقادیر فعلی فایل بماند (height 36، `--border`،
|
||||||
|
`--surface`، `minWidth 148`، fontSize 13، رنگ placeholder با `--text-3`).
|
||||||
|
|
||||||
|
### ۲. فعالکردن انتخاب سال برای تاریخ تولد
|
||||||
|
|
||||||
|
در `pages/PatientRecordFormPage.tsx:132` (فیلد `birth_date`) پراپ `enableYearPicker` را بده
|
||||||
|
تا کاربر بتواند سریع سال تولد را انتخاب کند:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PersianDateInput value={form.watch('birth_date') ?? ''} onChange={(v) => form.setValue('birth_date', v)} enableYearPicker />
|
||||||
|
```
|
||||||
|
|
||||||
|
(الگوی مشابه از قبل در `PatientRecordInfoForm.tsx:133` با `PersianDatePicker … enableYearPicker` هست.)
|
||||||
|
|
||||||
|
### ۳. رفع همپوشانی SOLID (اختیاری ولی توصیهشده)
|
||||||
|
|
||||||
|
بعد از این تغییر، `PersianDateInput` و `PersianDatePicker` تقریباً یکی میشوند
|
||||||
|
(هر دو = لایهی متنی + PersianCalendar). برای پرهیز از دوگانگی:
|
||||||
|
- گزینهی ساده: `PersianDateInput` را یک روکش نازک روی `PersianDatePicker` کن
|
||||||
|
(`return <PersianDatePicker {...props} />`)، یا
|
||||||
|
- در همین تسک فقط رفتار را یکی کن و در کامنت بالای فایل اشاره کن که این دو باید
|
||||||
|
در آینده ادغام شوند. حذف کامل یکی از آنها → نیازمند بهروزرسانی همهی 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/*` نیاز به تغییر ندارند.
|
||||||
@@ -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(<PersianDateInput value="" onChange={vi.fn()} placeholder="از تاریخ" />);
|
||||||
|
expect(screen.getByText('از تاریخ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('از تقویم شمسی استفاده میکند نه input بومی میلادی', () => {
|
||||||
|
const { container } = render(<PersianDateInput value="" onChange={vi.fn()} />);
|
||||||
|
expect(container.querySelector('input[type="date"]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('با کلیک، تقویم شمسی باز میشود (نام ماه فارسی)', () => {
|
||||||
|
const { container } = render(<PersianDateInput value="2024-03-25" onChange={vi.fn()} />);
|
||||||
|
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(<PersianDateInput value="2024-03-25" onChange={onChange} />);
|
||||||
|
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(<PersianDateInput value="2024-03-25" onChange={onChange} />);
|
||||||
|
const svgs = container.querySelectorAll('svg');
|
||||||
|
const clearIcon = svgs[svgs.length - 1];
|
||||||
|
fireEvent.click(clearIcon.parentElement as Element);
|
||||||
|
expect(onChange).toHaveBeenCalledWith('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,69 +1,17 @@
|
|||||||
import React, { useRef } from 'react';
|
import PersianDatePicker from './PersianDatePicker';
|
||||||
import { CalendarDaysIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
|
||||||
import { formatDate } from '../../lib/utils';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string; // YYYY-MM-DD میلادی
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
min?: string;
|
/** انتخاب سال→ماه→روز را فعال میکند (مناسب تاریخ تولد). */
|
||||||
max?: string;
|
enableYearPicker?: boolean;
|
||||||
style?: React.CSSProperties;
|
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PersianDateInput({ value, onChange, placeholder = 'انتخاب تاریخ', min, max, style, className }: Props) {
|
/**
|
||||||
const hiddenRef = useRef<HTMLInputElement>(null);
|
* روکش نازک روی PersianDatePicker تا انتخاب تاریخ در همهی صفحات با تقویم شمسی باشد.
|
||||||
|
* منبع واحد پیکر شمسی = PersianDatePicker/PersianCalendar.
|
||||||
const open = () => {
|
*/
|
||||||
const el = hiddenRef.current;
|
export default function PersianDateInput(props: Props) {
|
||||||
if (!el) return;
|
return <PersianDatePicker {...props} />;
|
||||||
if (typeof el.showPicker === 'function') {
|
|
||||||
try { el.showPicker(); } catch { el.focus(); }
|
|
||||||
} else {
|
|
||||||
el.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', ...style }} className={className}>
|
|
||||||
{/* visible text layer */}
|
|
||||||
<div
|
|
||||||
onClick={open}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 7,
|
|
||||||
height: 36, padding: '0 10px', borderRadius: 'var(--r-sm)',
|
|
||||||
border: '1px solid var(--border)', background: 'var(--surface)',
|
|
||||||
cursor: 'pointer', fontSize: 13, color: value ? 'var(--text)' : 'var(--text-3)',
|
|
||||||
userSelect: 'none', minWidth: 148, whiteSpace: 'nowrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CalendarDaysIcon style={{ width: 15, height: 15, color: 'var(--text-3)', flexShrink: 0 }} />
|
|
||||||
<span style={{ flex: 1 }}>{value ? formatDate(value) : placeholder}</span>
|
|
||||||
{value && (
|
|
||||||
<span
|
|
||||||
onClick={e => { e.stopPropagation(); onChange(''); }}
|
|
||||||
style={{ display: 'flex', alignItems: 'center', cursor: 'pointer', color: 'var(--text-3)' }}
|
|
||||||
>
|
|
||||||
<XMarkIcon style={{ width: 13, height: 13 }} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* hidden native input — opens picker on click */}
|
|
||||||
<input
|
|
||||||
ref={hiddenRef}
|
|
||||||
type="date"
|
|
||||||
value={value}
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
onChange={e => onChange(e.target.value)}
|
|
||||||
style={{
|
|
||||||
position: 'absolute', opacity: 0, pointerEvents: 'none',
|
|
||||||
width: 1, height: 1, top: 0, left: 0,
|
|
||||||
}}
|
|
||||||
tabIndex={-1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export default function PatientRecordFormPage() {
|
|||||||
<div className="field"><input {...form.register('mobile')} inputMode="numeric" placeholder="شماره تماس را وارد نمایید" /></div>
|
<div className="field"><input {...form.register('mobile')} inputMode="numeric" placeholder="شماره تماس را وارد نمایید" /></div>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="تاریخ تولد">
|
<Field label="تاریخ تولد">
|
||||||
<PersianDateInput value={form.watch('birth_date') ?? ''} onChange={(v) => form.setValue('birth_date', v)} />
|
<PersianDateInput value={form.watch('birth_date') ?? ''} onChange={(v) => form.setValue('birth_date', v)} enableYearPicker />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="نحوه آشنایی">
|
<Field label="نحوه آشنایی">
|
||||||
<div className="field"><select {...form.register('referral_source')} style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit' }}>
|
<div className="field"><select {...form.register('referral_source')} style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user