181 lines
9.4 KiB
Markdown
181 lines
9.4 KiB
Markdown
# جایگزینی همهی `<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`.
|