Files
clinicpro/.claude/prompt/admin-native-select-to-searchableselect.md
T

9.4 KiB

جایگزینی همه‌ی <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 (مرجع — تغییرش نده)

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 بده.
  • disabledisDisabled.

فایل‌های دارای <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 (بیشترین)

// 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

// 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)

// 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> را با این جایگزین کن:

<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 بده:

<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:

<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.