diff --git a/.claude/prompt/admin-native-select-to-searchableselect.md b/.claude/prompt/admin-native-select-to-searchableselect.md new file mode 100644 index 00000000..614ed610 --- /dev/null +++ b/.claude/prompt/admin-native-select-to-searchableselect.md @@ -0,0 +1,180 @@ +# جایگزینی همه‌ی `` بومی HTML استفاده شده (استایل درون‌خطی تکراری، +بدون جست‌وجو، ظاهر ناهماهنگ با طراحی‌سیستم، بدون RTL/dark درست). طراحی‌سیستم یک +کامپوننت مشترک دارد: `assets/admin/components/ui/SearchableSelect.tsx` (روی `react-select`، +قابل جست‌وجو، RTL، هماهنگ با توکن‌های CSS و dark mode، منو portal با `zIndex 9999`). + +**هدف:** همه‌ی ``: +- `onChange` مقدار خام می‌دهد (`string|number|null`) نه `e.target.value`. +- `options` باید `{ value, label }` باشد — نه ``) حذف و به `placeholder` منتقل شود؛ + اگر خالی‌کردن مجاز است `isClearable` بده. +- `disabled` → `isDisabled`. + +## فایل‌های دارای ` onChange(e.target.value)} style={sel}> + + {options.map(s => )} + +``` + +### الگوی 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} + /> +