From b1b99903ec77ace735a7ea4bf12aa03732dbc141 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 16 Jul 2026 08:56:59 +0330 Subject: [PATCH] Replace all native `` بومی پنل ادمین با `SearchableSelect` + +## پروژه + +`clinicpro` (React 19 admin SPA — `assets/admin/`) + +## زمینه + +در سراسر پنل ادمین از ``های بومی پنل با `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 مقدار بده +} +``` + +نکات مهمِ تفاوت با `` بومی (۱۶ فایل، ~۳۵ مورد) + +| فایل | تعداد | نکته | +|------|:----:|------| +| `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 + +``` + +### الگوی B — گزینه‌های وابسته / disabled +```tsx +// AppointmentFiltersModal.tsx:100 + +``` + +### الگوی C — React Hook Form با `register` (خاص — نیازمند Controller) +```tsx +// PatientRecordFormPage.tsx:119 +
+``` + +## وظایف + +> **یک فایل در هر مرحله.** بعد از هر فایل: `tsc` سبز شود، بعد فایل بعدی. ترتیب: از فایل‌های کم‌مورد به پرمورد، یا هر ترتیبی، ولی هر فایل مستقل تست/تایپ‌چک شود. + +### ۱. تبدیل الگوی A (controlled) + +هر `` بومیِ جدیدی در پنل ادمین نساز؛ + همیشه `SearchableSelect`. diff --git a/assets/admin/components/AppointmentActions.test.tsx b/assets/admin/components/AppointmentActions.test.tsx index 0c9cd8a5..a9643fc5 100644 --- a/assets/admin/components/AppointmentActions.test.tsx +++ b/assets/admin/components/AppointmentActions.test.tsx @@ -92,8 +92,8 @@ describe('AppointmentActionsMenu (عملیات نوبت)', () => { // the original slot is shown read-only expect(screen.getByDisplayValue('2024-12-31')).toBeDisabled(); expect(screen.getByDisplayValue('09:00')).toBeDisabled(); - // prefilled from the appointment's current specs - expect((screen.getByLabelText('وضعیت') as HTMLSelectElement).value).toBe('confirmed'); + // prefilled from the appointment's current specs (react-select single value) + expect(screen.getByText('قطعی شده')).toBeInTheDocument(); fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی'), { target: { value: 'ساغر صابری' } }); fireEvent.change(screen.getByPlaceholderText('شماره تماس'), { target: { value: '09356619438' } }); diff --git a/assets/admin/components/AppointmentActions.tsx b/assets/admin/components/AppointmentActions.tsx index ec6f8690..76607193 100644 --- a/assets/admin/components/AppointmentActions.tsx +++ b/assets/admin/components/AppointmentActions.tsx @@ -26,6 +26,7 @@ import AppointmentStatusDropdown from "./ui/AppointmentStatusDropdown"; import Modal from "./ui/Modal"; import PersianDateInput from "./ui/PersianDateInput"; import PriceInput from "./ui/PriceInput"; +import SearchableSelect from "./ui/SearchableSelect"; /** Row actions for the appointments table (Figma عملیات menu). */ type ModalKind = null | "info" | "move" | "transfer" | "replace"; @@ -732,16 +733,6 @@ export function ReplaceAppointmentModal({ }); const label = { fontSize: 12.5, color: "var(--text-3)" } as const; - const sel = { - width: "100%", - height: 38, - borderRadius: "var(--r-sm)", - border: "1px solid var(--border)", - background: "var(--surface)", - fontSize: 13, - fontFamily: "inherit", - padding: "0 10px", - } as const; const lockedField = { margin: "6px 0 12px", opacity: 0.6 } as const; const patients = patientsQ.data?.data ?? []; @@ -847,39 +838,32 @@ export function ReplaceAppointmentModal({ >
- +
+ ({ value: o.uuid, label: o.name ?? "" }))} + value={sectionUuid || null} + onChange={(v) => { setSectionUuid(v ? String(v) : ""); setItemUuid(""); }} + placeholder="انتخاب بخش" + isLoading={sectionsQ.isLoading} + isClearable + height={38} + /> +
- +
+ ({ value: o.uuid, label: o.name ?? "" }))} + value={itemUuid || null} + onChange={(v) => setItemUuid(v ? String(v) : "")} + placeholder="انتخاب زیر بخش" + isDisabled={!sectionUuid} + isLoading={itemsQ.isLoading} + isClearable + height={38} + /> +
@@ -958,35 +942,28 @@ export function ReplaceAppointmentModal({ - +
+ ({ value: o.uuid, label: o.full_name ?? "" }))} + value={staffUuid || null} + onChange={(v) => setStaffUuid(v ? String(v) : "")} + placeholder="انتخاب..." + isLoading={staffQ.isLoading} + isClearable + height={38} + /> +
- +
+ ({ value: v, label: l }))} + value={status || null} + onChange={(v) => setStatus((v ? String(v) : "") as Appointment["status"])} + placeholder="انتخاب وضعیت" + height={38} + /> +
@@ -91,17 +91,30 @@ export default function AppointmentFiltersModal({ value, onApply, onClose }: {
- +
+ ({ value: o.uuid, label: o.name ?? '' }))} + value={f.sectionUuid || null} + onChange={v => setF(prev => ({ ...prev, sectionUuid: v ? String(v) : '', itemUuid: '' }))} + placeholder="انتخاب بخش" + isLoading={sectionsQ.isLoading} + isClearable + height={38} + /> +
- +
+ ({ value: o.uuid, label: o.name ?? '' }))} + value={f.itemUuid || null} + onChange={v => setF(prev => ({ ...prev, itemUuid: v ? String(v) : '' }))} + placeholder="انتخاب سرویس" + isDisabled={!f.sectionUuid} + isLoading={itemsQ.isLoading} + isClearable + height={38} + /> +
وضعیت نوبت
diff --git a/assets/admin/components/NewAppointmentDrawer.test.tsx b/assets/admin/components/NewAppointmentDrawer.test.tsx index b6afa740..7991af9f 100644 --- a/assets/admin/components/NewAppointmentDrawer.test.tsx +++ b/assets/admin/components/NewAppointmentDrawer.test.tsx @@ -2,6 +2,16 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { screen, fireEvent, waitFor } from '@testing-library/react'; import { renderWithProviders } from '../test/utils'; +/** react-select (SearchableSelect) را با placeholder پیدا و گزینه را با متن انتخاب می‌کند. */ +async function pickSelect(placeholder: string, optionLabel: string) { + const ph = await screen.findByText(placeholder); + const control = ph.closest('div[class*="control"]') as HTMLElement; + const input = control.querySelector('input') as HTMLInputElement; + fireEvent.focus(input); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + fireEvent.click(await screen.findByText(optionLabel)); +} + vi.mock('../lib/api', () => ({ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, ApiError: class extends Error {}, @@ -53,12 +63,9 @@ describe('NewAppointmentDrawer (اضافه کردن نوبت جدید)', () => { fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده را وارد نمایید'), { target: { value: 'مریم خلیلی' } }); fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده را وارد نمایید'), { target: { value: '09136549874' } }); fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده را وارد نمایید'), { target: { value: '1234567891' } }); - await screen.findByRole('option', { name: 'زیبایی' }); - fireEvent.change(screen.getByLabelText('بخش'), { target: { value: 'sec1' } }); - await screen.findByRole('option', { name: 'لیزر توتال' }); - fireEvent.change(screen.getByLabelText('سرویس'), { target: { value: 'it1' } }); - await screen.findByRole('option', { name: 'سحر ایمانی' }); - fireEvent.change(screen.getByLabelText('پرسنل'), { target: { value: 'st1' } }); + await pickSelect('انتخاب بخش', 'زیبایی'); + await pickSelect('انتخاب سرویس', 'لیزر توتال'); + await pickSelect('انتخاب...', 'سحر ایمانی'); fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' })); await waitFor(() => expect(post).toHaveBeenCalledWith('/api/v1/my/appointment', expect.objectContaining({ @@ -112,9 +119,8 @@ describe('NewAppointmentDrawer (اضافه کردن نوبت جدید)', () => { // حالت سرویس: منوی زمان‌دهیِ دستی نباید باشد await waitFor(() => expect(screen.queryByLabelText('ساعت شروع')).toBeNull()); - fireEvent.change(screen.getByLabelText('بخش'), { target: { value: 'sec1' } }); - await screen.findByRole('option', { name: 'لیزر توتال' }); - fireEvent.change(screen.getByLabelText('سرویس'), { target: { value: 'it1' } }); + await pickSelect('انتخاب بخش', 'زیبایی'); + await pickSelect('افزودن سرویس', 'لیزر توتال'); // زمانِ خالیِ پیشنهادی ظاهر می‌شود؛ انتخاب می‌کنیم const slotBtn = await screen.findByRole('button', { name: '15:00' }); diff --git a/assets/admin/components/NewAppointmentDrawer.tsx b/assets/admin/components/NewAppointmentDrawer.tsx index 730e398c..47540190 100644 --- a/assets/admin/components/NewAppointmentDrawer.tsx +++ b/assets/admin/components/NewAppointmentDrawer.tsx @@ -7,6 +7,7 @@ import type { ApiResponse } from '../lib/api'; import Modal from './ui/Modal'; import PersianDateInput from './ui/PersianDateInput'; import PriceInput from './ui/PriceInput'; +import SearchableSelect from './ui/SearchableSelect'; import { WalletChargeLink } from './AppointmentActions'; import { tehranWallClockToUnix } from '../lib/utils'; @@ -153,7 +154,6 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey }); const label = { fontSize: 12.5, color: 'var(--text-3)' } as const; - const sel = { width: '100%', height: 38, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 10px' } as const; const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]); return ( @@ -205,33 +205,41 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
- +
+ ({ value: o.uuid, label: o.name ?? '' }))} + value={sectionUuid || null} + onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }} + placeholder="انتخاب بخش" + isLoading={sectionsQ.isLoading} + isClearable + height={38} + /> +
- +
+ ({ value: o.uuid, label: o.name ?? '' }))} + value={serviceMode ? null : (itemUuid || null)} + isDisabled={!sectionUuid} + isLoading={itemsQ.isLoading} + placeholder={serviceMode ? 'افزودن سرویس' : 'انتخاب سرویس'} + onChange={v => { + const uuid = v ? String(v) : ''; + if (!uuid) { if (!serviceMode) setItemUuid(''); return; } + if (serviceMode) { + const name = (itemsQ.data?.data ?? []).find(o => o.uuid === uuid)?.name ?? ''; + setServiceUuids(prev => prev.includes(uuid) ? prev : [...prev, uuid]); + setSvcNames(prev => ({ ...prev, [uuid]: name })); + } else { + setItemUuid(uuid); + } + }} + height={38} + /> +
{serviceMode && serviceUuids.length > 0 && ( @@ -247,10 +255,17 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
)} - +
+ ({ value: o.uuid, label: o.full_name ?? '' }))} + value={staffUuid || null} + onChange={v => setStaffUuid(v ? String(v) : '')} + placeholder="انتخاب..." + isLoading={staffQ.isLoading} + isClearable + height={38} + /> +
زمان نوبت:
@@ -326,10 +341,15 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey )} - +
+ setStatus(v ? String(v) : '')} + placeholder="انتخاب وضعیت" + height={38} + /> +