Replace all native <select> elements in the admin panel with SearchableSelect for a consistent UI experience. This change enhances accessibility, supports RTL and dark mode, and improves the overall design by utilizing a common component. The updates include adjustments to state management and event handling to ensure seamless integration with existing functionality across various pages and components.

This commit is contained in:
hamed
2026-07-16 08:56:59 +03:30
parent 7b8a5c2775
commit b1b99903ec
21 changed files with 571 additions and 288 deletions
@@ -0,0 +1,180 @@
# جایگزینی همه‌ی `<select>` بومی پنل ادمین با `SearchableSelect`
## پروژه
`clinicpro` (React 19 admin SPA — `assets/admin/`)
## زمینه
در سراسر پنل ادمین از `<select>` بومی HTML استفاده شده (استایل درون‌خطی تکراری،
بدون جست‌وجو، ظاهر ناهماهنگ با طراحی‌سیستم، بدون RTL/dark درست). طراحی‌سیستم یک
کامپوننت مشترک دارد: `assets/admin/components/ui/SearchableSelect.tsx` (روی `react-select`،
قابل جست‌وجو، RTL، هماهنگ با توکن‌های CSS و dark mode، منو portal با `zIndex 9999`).
**هدف:** همه‌ی `<select>`های بومی پنل با `SearchableSelect` جایگزین شوند تا ظاهر و رفتار
یکدست شود (مثل دسته‌بندی «افزودن کالا» در `AddItemModal.tsx` که از قبل `SearchableSelect` است).
## API کامپوننت `SearchableSelect` (مرجع — تغییرش نده)
```tsx
interface SelectOption { value: string | number; label: string }
interface Props {
options: SelectOption[];
value?: string | number | null;
onChange?: (value: string | number | null) => void; // مقدار خام، نه event
placeholder?: string; // پیش‌فرض «انتخاب کنید...»
isLoading?: boolean;
isDisabled?: boolean;
isClearable?: boolean;
noOptionsMessage?: string; // پیش‌فرض «موردی یافت نشد»
inputId?: string;
height?: number; // پیش‌فرض 42؛ برای هم‌ارتفاعی با فیلدهای 38px مقدار بده
}
```
نکات مهمِ تفاوت با `<select>`:
- `onChange` مقدار خام می‌دهد (`string|number|null`) نه `e.target.value`.
- `options` باید `{ value, label }` باشد — نه `<option>`.
- گزینه‌ی خالی (`<option value="">انتخاب...</option>`) حذف و به `placeholder` منتقل شود؛
اگر خالی‌کردن مجاز است `isClearable` بده.
- `disabled``isDisabled`.
## فایل‌های دارای `<select>` بومی (۱۶ فایل، ~۳۵ مورد)
| فایل | تعداد | نکته |
|------|:----:|------|
| `pages/MyPatientsPage.tsx` | 4 | فیلترها |
| `pages/AppointmentEditPage.tsx` | 4 | |
| `pages/AppointmentCreatePage.tsx` | 4 | |
| `components/NewAppointmentDrawer.tsx` | 4 | بخش/زیربخش/سرویس (وابسته) |
| `components/AppointmentActions.tsx` | 4 | خطوط 850,869,961,976 |
| `pages/DoctorDetailPage.tsx` | 3 | |
| `pages/PatientRecordFormPage.tsx` | 2 | **RHF `register`** (gender:119، referral_source:135) |
| `components/AppointmentFiltersModal.tsx` | 2 | `sel` استایل مشترک؛ سرویس وابسته به بخش (`disabled`) |
| `pages/ReserveAppointmentsPage.tsx` | 1 | |
| `pages/RepresentationSettlementPage.tsx` | 1 | |
| `pages/MyPaymentsPage.tsx` | 1 | |
| `pages/InventoryPage.tsx` | 1 | فیلتر دسته |
| `pages/AppointmentsPage.tsx` | 1 | سرویس (خط 653) |
| `components/inventory/AddPackageModal.tsx` | 1 | |
| `components/dashboard/TauriDashboardView.tsx` | 1 | |
| `components/PatientsFilterModal.tsx` | 1 | |
## وضعیت فعلی — سه الگوی رایج
### الگوی A — controlled با `value` + `onChange` (بیشترین)
```tsx
// AppointmentsPage.tsx:653
<select aria-label="سرویس" value={value} onChange={e => onChange(e.target.value)} style={sel}>
<option value="">سرویس مورد نظر را انتخاب کنید...</option>
{options.map(s => <option key={s.uuid} value={s.uuid}>{s.name}</option>)}
</select>
```
### الگوی B — گزینه‌های وابسته / disabled
```tsx
// AppointmentFiltersModal.tsx:100
<select aria-label="سرویس" style={{ ...sel, margin: '6px 0 14px' }} value={f.itemUuid} disabled={!f.sectionUuid}
onChange={...}>
<option value="">انتخاب سرویس</option>
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
</select>
```
### الگوی C — React Hook Form با `register` (خاص — نیازمند Controller)
```tsx
// PatientRecordFormPage.tsx:119
<div className="field"><select {...form.register('gender')} style={{...}}>
<option value="">انتخاب...</option>
<option value="female">زن</option>
<option value="male">مرد</option>
</select></div>
```
## وظایف
> **یک فایل در هر مرحله.** بعد از هر فایل: `tsc` سبز شود، بعد فایل بعدی. ترتیب: از فایل‌های کم‌مورد به پرمورد، یا هر ترتیبی، ولی هر فایل مستقل تست/تایپ‌چک شود.
### ۱. تبدیل الگوی A (controlled)
هر `<select value onChange>` را با این جایگزین کن:
```tsx
<SearchableSelect
options={options.map(s => ({ value: s.uuid, label: s.name }))}
value={value || null}
onChange={v => onChange(v ? String(v) : '')} // اگر state رشته است String() بزن
placeholder="سرویس مورد نظر را انتخاب کنید..." // همان متن option خالی
isClearable // اگر خالی‌کردن مجاز بود
height={38} // برای هم‌ارتفاعی با فیلدهای فعلی
/>
```
- import در بالای فایل: `import SearchableSelect from '../ui/SearchableSelect';`
(عمق مسیر را بر اساس محل فایل تنظیم کن: از `pages/``'../components/ui/SearchableSelect'`).
- استایل درون‌خطی `sel`/`style` روی select حذف شود (کامپوننت خودش استایل دارد).
اگر `margin` بیرونی لازم بود، در یک `<div style={{ margin }}>` دور کامپوننت بگذار.
- `aria-label` را حفظ کن: چون react-select خودش input دارد، برای دسترس‌پذیری از
`inputId` + یک `<label htmlFor>` مخفی یا `aria-label` روی wrapper استفاده کن (اختیاری ولی بهتر).
### ۲. تبدیل الگوی B (وابسته/disabled)
مثل A ولی `isDisabled` را از شرط قبلی بده و در صورت لود‌شدن آسنکرون `isLoading` را از
`query.isLoading` بده:
```tsx
<SearchableSelect
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name }))}
value={f.itemUuid || null}
onChange={v => setF({ ...f, itemUuid: v ? String(v) : '' })}
placeholder="انتخاب سرویس"
isDisabled={!f.sectionUuid}
isLoading={itemsQ.isLoading}
isClearable
height={38}
/>
```
### ۳. تبدیل الگوی C (React Hook Form)
`register` روی `SearchableSelect` کار نمی‌کند (input بومی نیست). دو راه — راه ساده `watch`+`setValue`:
```tsx
<SearchableSelect
options={[{ value: 'female', label: 'زن' }, { value: 'male', label: 'مرد' }]}
value={form.watch('gender') || null}
onChange={v => form.setValue('gender', v ? String(v) : '', { shouldValidate: true, shouldDirty: true })}
placeholder="انتخاب..."
isClearable
height={38}
/>
```
- برای فیلدهای الزامیِ Zod، `shouldValidate: true` را نگه دار تا خطاها به‌روز شوند.
- اگر فیلد قبلاً از `REFERRAL_OPTIONS` می‌ساخت: `REFERRAL_OPTIONS.map(o => ({ value: o, label: o }))`.
### ۴. حذف کد مرده
بعد از تبدیل، هر متغیر استایل مشترکِ بلااستفاده (`const sel = {...}`) و importهای بی‌استفاده
را حذف کن (`tsc`/eslint نشان می‌دهد).
## نکات مهم
- **رفتار داده تغییر نکند:** مقداری که به state/RHF/API می‌رود همان `uuid`/کلید قبلی بماند
(فقط `''``null` را مدیریت کن — `SearchableSelect` هنگام خالی `null` می‌دهد، اگر state
انتظار رشته دارد به `''` تبدیل کن).
- **ارتفاع:** فیلدهای فعلی معمولاً `height: 38px`‌اند؛ `height={38}` بده تا ردیف‌ها جابه‌جا نشوند.
پیش‌فرض کامپوننت 42 است.
- **گزینه‌های وابسته (بخش→زیربخش→سرویس در NewAppointmentDrawer / AppointmentActions):**
با تغییر والد، مقدار فرزند ریست شود (همان منطق فعلی onChange والد را حفظ کن).
- **منوی داخل Modal/Drawer:** `SearchableSelect` منو را با `menuPortal`+`zIndex 9999` به `body`
می‌برد؛ مشکل بریدگی/overflow نخواهد داشت — نیازی به تنظیم اضافه نیست.
- **تست:** بعد از هر فایل `ddev exec npx tsc --noEmit --project tsconfig.json`؛ در پایان
`npx vitest run` (روی host؛ داخل ddev esbuild پلتفرم mismatch دارد). تست‌های موجودِ
فایل‌هایی که select داشتند (مثل `AppointmentFiltersModal.test.tsx`، `PatientRecordInfoForm.test.tsx`)
را اجرا کن؛ اگر با `getByRole('combobox')`/`selectOptions` بودند، به تعامل react-select
(کلیک + انتخاب گزینه با متن) به‌روز کن.
- **بدون کتابخانه جدید:** `react-select` از قبل نصب است. RTL و dark از خود کامپوننت می‌آید.
- تغییر فقط-UI است؛ backend و `docs/api/*` تغییری ندارد.
- **این یک قاعده‌ی دائمی است:** از این پس هیچ `<select>` بومیِ جدیدی در پنل ادمین نساز؛
همیشه `SearchableSelect`.