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:
@@ -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`.
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
<div>
|
||||
<label style={label}>بخش</label>
|
||||
<select
|
||||
aria-label="بخش"
|
||||
style={{ ...sel, marginTop: 6 }}
|
||||
value={sectionUuid}
|
||||
onChange={(e) => {
|
||||
setSectionUuid(e.target.value);
|
||||
setItemUuid("");
|
||||
}}
|
||||
>
|
||||
<option value="">انتخاب بخش</option>
|
||||
{(sectionsQ.data?.data ?? []).map((o) => (
|
||||
<option key={o.uuid} value={o.uuid}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(sectionsQ.data?.data ?? []).map((o) => ({ value: o.uuid, label: o.name ?? "" }))}
|
||||
value={sectionUuid || null}
|
||||
onChange={(v) => { setSectionUuid(v ? String(v) : ""); setItemUuid(""); }}
|
||||
placeholder="انتخاب بخش"
|
||||
isLoading={sectionsQ.isLoading}
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>سرویس</label>
|
||||
<select
|
||||
aria-label="سرویس"
|
||||
style={{ ...sel, marginTop: 6 }}
|
||||
value={itemUuid}
|
||||
onChange={(e) => setItemUuid(e.target.value)}
|
||||
disabled={!sectionUuid}
|
||||
>
|
||||
<option value="">انتخاب زیر بخش</option>
|
||||
{(itemsQ.data?.data ?? []).map((o) => (
|
||||
<option key={o.uuid} value={o.uuid}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(itemsQ.data?.data ?? []).map((o) => ({ value: o.uuid, label: o.name ?? "" }))}
|
||||
value={itemUuid || null}
|
||||
onChange={(v) => setItemUuid(v ? String(v) : "")}
|
||||
placeholder="انتخاب زیر بخش"
|
||||
isDisabled={!sectionUuid}
|
||||
isLoading={itemsQ.isLoading}
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -958,35 +942,28 @@ export function ReplaceAppointmentModal({
|
||||
</div>
|
||||
|
||||
<label style={label}>انتخاب پرسنل</label>
|
||||
<select
|
||||
aria-label="پرسنل"
|
||||
style={{ ...sel, margin: "6px 0 12px" }}
|
||||
value={staffUuid}
|
||||
onChange={(e) => setStaffUuid(e.target.value)}
|
||||
>
|
||||
<option value="">انتخاب...</option>
|
||||
{(staffQ.data?.data ?? []).map((o) => (
|
||||
<option key={o.uuid} value={o.uuid}>
|
||||
{o.full_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ margin: "6px 0 12px" }}>
|
||||
<SearchableSelect
|
||||
options={(staffQ.data?.data ?? []).map((o) => ({ value: o.uuid, label: o.full_name ?? "" }))}
|
||||
value={staffUuid || null}
|
||||
onChange={(v) => setStaffUuid(v ? String(v) : "")}
|
||||
placeholder="انتخاب..."
|
||||
isLoading={staffQ.isLoading}
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label style={label}>انتخاب وضعیت</label>
|
||||
<select
|
||||
aria-label="وضعیت"
|
||||
style={{ ...sel, margin: "6px 0 12px" }}
|
||||
value={status}
|
||||
onChange={(e) =>
|
||||
setStatus(e.target.value as Appointment["status"])
|
||||
}
|
||||
>
|
||||
{statusOptions.map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ margin: "6px 0 12px" }}>
|
||||
<SearchableSelect
|
||||
options={statusOptions.map(([v, l]) => ({ value: v, label: l }))}
|
||||
value={status || null}
|
||||
onChange={(v) => setStatus((v ? String(v) : "") as Appointment["status"])}
|
||||
placeholder="انتخاب وضعیت"
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label style={label}>توضیحات</label>
|
||||
<div
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { Appointment } from '../types';
|
||||
import Modal from './ui/Modal';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
|
||||
interface Option { uuid: string; name?: string }
|
||||
|
||||
@@ -70,7 +71,6 @@ export default function AppointmentFiltersModal({ value, onApply, onClose }: {
|
||||
}));
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
<Modal open title="فیلترها" onClose={onClose}>
|
||||
@@ -91,17 +91,30 @@ export default function AppointmentFiltersModal({ value, onApply, onClose }: {
|
||||
</div>
|
||||
|
||||
<label style={label}>بخش</label>
|
||||
<select aria-label="بخش" style={{ ...sel, margin: '6px 0 12px' }} value={f.sectionUuid}
|
||||
onChange={e => setF(v => ({ ...v, sectionUuid: e.target.value, itemUuid: '' }))}>
|
||||
<option value="">انتخاب بخش</option>
|
||||
{(sectionsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
<div style={{ margin: '6px 0 12px' }}>
|
||||
<SearchableSelect
|
||||
options={(sectionsQ.data?.data ?? []).map(o => ({ 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}
|
||||
/>
|
||||
</div>
|
||||
<label style={label}>سرویس</label>
|
||||
<select aria-label="سرویس" style={{ ...sel, margin: '6px 0 14px' }} value={f.itemUuid} disabled={!f.sectionUuid}
|
||||
onChange={e => setF(v => ({ ...v, itemUuid: e.target.value }))}>
|
||||
<option value="">انتخاب سرویس</option>
|
||||
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
<div style={{ margin: '6px 0 14px' }}>
|
||||
<SearchableSelect
|
||||
options={(itemsQ.data?.data ?? []).map(o => ({ 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 13.5, fontWeight: 700, marginBottom: 8 }}>وضعیت نوبت</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 14 }}>
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 10 }}>
|
||||
<div>
|
||||
<label style={label}>بخش</label>
|
||||
<select aria-label="بخش" style={{ ...sel, marginTop: 6 }} value={sectionUuid} onChange={e => { setSectionUuid(e.target.value); setItemUuid(''); }}>
|
||||
<option value="">انتخاب بخش</option>
|
||||
{(sectionsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
||||
value={sectionUuid || null}
|
||||
onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }}
|
||||
placeholder="انتخاب بخش"
|
||||
isLoading={sectionsQ.isLoading}
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>سرویس{serviceMode ? ' (یک یا چند)' : ''}</label>
|
||||
<select
|
||||
aria-label="سرویس"
|
||||
style={{ ...sel, marginTop: 6 }}
|
||||
value={serviceMode ? '' : itemUuid}
|
||||
disabled={!sectionUuid}
|
||||
onChange={e => {
|
||||
const uuid = e.target.value;
|
||||
if (!uuid) 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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">{serviceMode ? 'افزودن سرویس' : 'انتخاب سرویس'}</option>
|
||||
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(itemsQ.data?.data ?? []).map(o => ({ 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{serviceMode && serviceUuids.length > 0 && (
|
||||
@@ -247,10 +255,17 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
</div>
|
||||
)}
|
||||
<label style={label}>انتخاب پرسنل</label>
|
||||
<select aria-label="پرسنل" style={{ ...sel, margin: '6px 0 12px' }} value={staffUuid} onChange={e => setStaffUuid(e.target.value)}>
|
||||
<option value="">انتخاب...</option>
|
||||
{(staffQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.full_name}</option>)}
|
||||
</select>
|
||||
<div style={{ margin: '6px 0 12px' }}>
|
||||
<SearchableSelect
|
||||
options={(staffQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.full_name ?? '' }))}
|
||||
value={staffUuid || null}
|
||||
onChange={v => setStaffUuid(v ? String(v) : '')}
|
||||
placeholder="انتخاب..."
|
||||
isLoading={staffQ.isLoading}
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>زمان نوبت:</div>
|
||||
<label style={label}>انتخاب تاریخ</label>
|
||||
@@ -326,10 +341,15 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
)}
|
||||
|
||||
<label style={label}>انتخاب وضعیت</label>
|
||||
<select aria-label="وضعیت" style={{ ...sel, margin: '6px 0 12px' }} value={status} onChange={e => setStatus(e.target.value)}>
|
||||
<option value="pending">ثبت شده</option>
|
||||
<option value="confirmed">قطعی شده</option>
|
||||
</select>
|
||||
<div style={{ margin: '6px 0 12px' }}>
|
||||
<SearchableSelect
|
||||
options={[{ value: 'pending', label: 'ثبت شده' }, { value: 'confirmed', label: 'قطعی شده' }]}
|
||||
value={status || null}
|
||||
onChange={v => setStatus(v ? String(v) : '')}
|
||||
placeholder="انتخاب وضعیت"
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field" style={{ height: 'auto', marginBottom: 16 }}>
|
||||
<textarea value={note} onChange={e => setNote(e.target.value)} rows={3} placeholder="توضیحات..."
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import Modal from './ui/Modal';
|
||||
import PersianDateInput from './ui/PersianDateInput';
|
||||
import SearchableSelect from './ui/SearchableSelect';
|
||||
|
||||
export interface PatientFilters {
|
||||
gender?: string; // male | female
|
||||
@@ -89,10 +90,14 @@ export default function PatientsFilterModal({ open, onClose, value, onApply }: {
|
||||
|
||||
<div>
|
||||
<label style={label}>نوع بیمه</label>
|
||||
<select className="input" value={f.insurance_id ?? ''} onChange={(e) => set('insurance_id', e.target.value || undefined)} aria-label="نوع بیمه">
|
||||
<option value="">همه بیمهها</option>
|
||||
{insurances.map((i) => <option key={i.insurance_id} value={String(i.insurance_id)}>{i.insurance_name}</option>)}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
options={insurances.map((i) => ({ value: String(i.insurance_id), label: i.insurance_name }))}
|
||||
value={f.insurance_id ?? null}
|
||||
onChange={(v) => set('insurance_id', v ? String(v) : undefined)}
|
||||
placeholder="همه بیمهها"
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -9,27 +9,19 @@ import { Link } from 'react-router-dom';
|
||||
import { TauriStatCards, type DashboardStats } from './TauriStatCards';
|
||||
import { TauriBarChart, TauriLineChart, type ChartPoint } from './TauriCharts';
|
||||
import { NewAppointmentsTable, type ApptRow } from './NewAppointmentsTable';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
|
||||
/** small cosmetic dropdown — mirrors source SmSelector (does not drive data) */
|
||||
function SmSelector({ options }: { options: string[] }) {
|
||||
const [value, setValue] = React.useState<string | number | null>(options[0] ?? null);
|
||||
return (
|
||||
<div className="relative">
|
||||
<select
|
||||
className="appearance-none bg-transparent text-[#7E7E7E] dark:text-[#A1A1A1] text-[12px] font-normal min-w-[92px] rounded-[6px] border border-solid border-[#D7D7D7] dark:border-[#35343D] py-[8px] pr-[8px] pl-[24px] cursor-pointer"
|
||||
defaultValue={options[0]}
|
||||
aria-label="بازه"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o}>{o}</option>
|
||||
))}
|
||||
</select>
|
||||
<svg
|
||||
className="pointer-events-none absolute left-[6px] top-1/2 -translate-y-1/2"
|
||||
width="16" height="16" viewBox="0 0 20 20" fill="none"
|
||||
>
|
||||
<path d="M16.6004 7.4585L11.1671 12.8918C10.5254 13.5335 9.47539 13.5335 8.83372 12.8918L3.40039 7.4585"
|
||||
stroke="#7E7E7E" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<div style={{ minWidth: 110 }}>
|
||||
<SearchableSelect
|
||||
options={options.map((o) => ({ value: o, label: o }))}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
height={34}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { TrashIcon } from '@heroicons/react/24/outline';
|
||||
import Modal from '../ui/Modal';
|
||||
import SearchableSelect from '../ui/SearchableSelect';
|
||||
import { formatRial } from '../../lib/utils';
|
||||
import type { InventoryItem, InventoryPackage, PackagePayload } from '../../hooks/useInventory';
|
||||
|
||||
@@ -76,12 +77,14 @@ export default function AddPackageModal({ open, editing, items, saving, onClose,
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">اجزای پکیج</label>
|
||||
<div className="field">
|
||||
<select value={pickUuid} onChange={(e) => setPickUuid(e.target.value)} disabled={items.length === 0}>
|
||||
{items.length === 0 && <option value="">ابتدا کالا اضافه کنید</option>}
|
||||
{items.map((i) => <option key={i.uuid} value={i.uuid}>{i.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<SearchableSelect
|
||||
options={items.map((i) => ({ value: i.uuid, label: i.name }))}
|
||||
value={pickUuid || null}
|
||||
onChange={(v) => setPickUuid(v ? String(v) : '')}
|
||||
placeholder={items.length === 0 ? 'ابتدا کالا اضافه کنید' : 'انتخاب کالا'}
|
||||
isDisabled={items.length === 0}
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="field-label">مقدار</label>
|
||||
|
||||
@@ -31,7 +31,6 @@ const addMinutes = (time: string, min: number) => {
|
||||
};
|
||||
|
||||
const label = { fontSize: 12.5, color: 'var(--text-3)', display: 'block' } as const;
|
||||
const sel: React.CSSProperties = { width: '100%', height: 38, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 10px' };
|
||||
const sectionTitle: React.CSSProperties = { fontSize: 14, fontWeight: 700, color: 'var(--text)', margin: '18px 0 12px' };
|
||||
|
||||
export default function AppointmentCreatePage() {
|
||||
@@ -228,25 +227,47 @@ export default function AppointmentCreatePage() {
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 10 }}>
|
||||
<div>
|
||||
<label style={label}>بخش</label>
|
||||
<select aria-label="بخش" style={{ ...sel, marginTop: 6 }} value={sectionUuid} onChange={e => { setSectionUuid(e.target.value); setItemUuid(''); }}>
|
||||
<option value="">انتخاب بخش</option>
|
||||
{(sectionsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
||||
value={sectionUuid || null}
|
||||
onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }}
|
||||
placeholder="انتخاب بخش"
|
||||
isLoading={sectionsQ.isLoading}
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>سرویس</label>
|
||||
<select aria-label="سرویس" style={{ ...sel, marginTop: 6 }} value={itemUuid} onChange={e => setItemUuid(e.target.value)} disabled={!sectionUuid}>
|
||||
<option value="">انتخاب سرویس</option>
|
||||
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
||||
value={itemUuid || null}
|
||||
onChange={v => setItemUuid(v ? String(v) : '')}
|
||||
placeholder="انتخاب سرویس"
|
||||
isDisabled={!sectionUuid}
|
||||
isLoading={itemsQ.isLoading}
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<label style={label}>انتخاب پرسنل</label>
|
||||
<select aria-label="پرسنل" style={{ ...sel, margin: '6px 0 4px' }} value={staffUuid} onChange={e => setStaffUuid(e.target.value)}>
|
||||
<option value="">انتخاب...</option>
|
||||
{(staffQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.full_name}</option>)}
|
||||
</select>
|
||||
<div style={{ margin: '6px 0 4px' }}>
|
||||
<SearchableSelect
|
||||
options={(staffQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.full_name ?? '' }))}
|
||||
value={staffUuid || null}
|
||||
onChange={v => setStaffUuid(v ? String(v) : '')}
|
||||
placeholder="انتخاب..."
|
||||
isLoading={staffQ.isLoading}
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* زمان نوبت */}
|
||||
<div style={sectionTitle}>زمان نوبت:</div>
|
||||
@@ -301,10 +322,15 @@ export default function AppointmentCreatePage() {
|
||||
)}
|
||||
|
||||
<label style={label}>انتخاب وضعیت</label>
|
||||
<select aria-label="وضعیت" style={{ ...sel, margin: '6px 0 12px' }} value={status} onChange={e => setStatus(e.target.value)}>
|
||||
<option value="pending">ثبت شده</option>
|
||||
<option value="confirmed">قطعی شده</option>
|
||||
</select>
|
||||
<div style={{ margin: '6px 0 12px' }}>
|
||||
<SearchableSelect
|
||||
options={[{ value: 'pending', label: 'ثبت شده' }, { value: 'confirmed', label: 'قطعی شده' }]}
|
||||
value={status || null}
|
||||
onChange={v => setStatus(v ? String(v) : '')}
|
||||
placeholder="انتخاب وضعیت"
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label style={label}>توضیحات</label>
|
||||
<div className="field" style={{ height: 'auto', margin: '6px 0 16px' }}>
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('AppointmentEditPage (ویرایش نوبت)', () => {
|
||||
renderEdit();
|
||||
expect(await screen.findByText('مشخصات سرویس:')).toBeInTheDocument();
|
||||
expect((screen.getByLabelText('ساعت شروع') as HTMLInputElement).value).toBe('15:00');
|
||||
expect((screen.getByLabelText('وضعیت') as HTMLSelectElement).value).toBe('confirmed');
|
||||
expect(screen.getByText('قطعی شده')).toBeInTheDocument(); // react-select single value = confirmed
|
||||
expect(screen.getByDisplayValue('یادداشت')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import PriceInput from '../components/ui/PriceInput';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { WalletChargeLink } from '../components/AppointmentActions';
|
||||
|
||||
interface Option { uuid: string; name?: string; full_name?: string }
|
||||
@@ -100,7 +101,6 @@ export default function AppointmentEditPage() {
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
if (isLoading || !a) return <div style={{ padding: 24, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>;
|
||||
|
||||
@@ -122,24 +122,46 @@ export default function AppointmentEditPage() {
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 18 }}>
|
||||
<div>
|
||||
<label style={label}>بخش</label>
|
||||
<select aria-label="بخش" style={{ ...sel, marginTop: 6 }} value={sectionUuid} onChange={e => { setSectionUuid(e.target.value); setItemUuid(''); }}>
|
||||
<option value="">انتخاب بخش</option>
|
||||
{(sectionsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
||||
value={sectionUuid || null}
|
||||
onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }}
|
||||
placeholder="انتخاب بخش"
|
||||
isLoading={sectionsQ.isLoading}
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>سرویس</label>
|
||||
<select aria-label="سرویس" style={{ ...sel, marginTop: 6 }} value={itemUuid} onChange={e => setItemUuid(e.target.value)} disabled={!sectionUuid}>
|
||||
<option value="">انتخاب سرویس</option>
|
||||
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
||||
value={itemUuid || null}
|
||||
onChange={v => setItemUuid(v ? String(v) : '')}
|
||||
placeholder="انتخاب سرویس"
|
||||
isDisabled={!sectionUuid}
|
||||
isLoading={itemsQ.isLoading}
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>پرسنل</label>
|
||||
<select aria-label="پرسنل" style={{ ...sel, marginTop: 6 }} value={staffUuid} onChange={e => setStaffUuid(e.target.value)}>
|
||||
<option value="">انتخاب پرسنل</option>
|
||||
{(staffQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.full_name}</option>)}
|
||||
</select>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={(staffQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.full_name ?? '' }))}
|
||||
value={staffUuid || null}
|
||||
onChange={v => setStaffUuid(v ? String(v) : '')}
|
||||
placeholder="انتخاب پرسنل"
|
||||
isLoading={staffQ.isLoading}
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -178,9 +200,15 @@ export default function AppointmentEditPage() {
|
||||
|
||||
<div style={{ maxWidth: 320, marginBottom: 18 }}>
|
||||
<label style={label}>انتخاب وضعیت</label>
|
||||
<select aria-label="وضعیت" style={{ ...sel, marginTop: 6 }} value={status} onChange={e => setStatus(e.target.value)}>
|
||||
{statusOptions.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<SearchableSelect
|
||||
options={statusOptions.map(([v, l]) => ({ value: v, label: l }))}
|
||||
value={status || null}
|
||||
onChange={v => setStatus(v ? String(v) : '')}
|
||||
placeholder="انتخاب وضعیت"
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style={label}>توضیحات</label>
|
||||
|
||||
@@ -15,6 +15,7 @@ import Pagination from '../components/ui/Pagination';
|
||||
import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal';
|
||||
import type { AppointmentFilters } from '../components/AppointmentFiltersModal';
|
||||
import PersianCalendar from '../components/ui/PersianCalendar';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
// اجزای طرح نوبتهای tauri
|
||||
import TurnsStatInfo from '../components/appointments/TurnsStatInfo';
|
||||
import TurnsViewToggle from '../components/appointments/TurnsViewToggle';
|
||||
@@ -650,10 +651,15 @@ function ServiceFilterSelect({ value, options, onChange }: {
|
||||
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<select aria-label="سرویس" value={value} onChange={e => onChange(e.target.value)}
|
||||
style={{ height: 44, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 12px', minWidth: 280 }}>
|
||||
<option value="">سرویس مورد نظر را انتخاب کنید...</option>
|
||||
{options.map(s => <option key={s.uuid} value={s.uuid}>{s.name}</option>)}
|
||||
</select>
|
||||
<div style={{ minWidth: 280 }}>
|
||||
<SearchableSelect
|
||||
options={options.map(s => ({ value: s.uuid, label: s.name }))}
|
||||
value={value || null}
|
||||
onChange={v => onChange(v ? String(v) : '')}
|
||||
placeholder="سرویس مورد نظر را انتخاب کنید..."
|
||||
isClearable
|
||||
height={44}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1053,15 +1053,23 @@ function TimeSelect({ value, onChange }: { value: string; onChange: (v: string)
|
||||
|
||||
return (
|
||||
<div className="cp-time-select" style={{ display: 'flex', alignItems: 'center', gap: 6 }} dir="ltr">
|
||||
<select className="cp-input" style={{ height: 38, width: 72, textAlign: 'center' }}
|
||||
value={h} onChange={e => onChange(`${e.target.value}:${m}`)}>
|
||||
{HOUR_VALUES.map(hv => <option key={hv} value={hv}>{hv}</option>)}
|
||||
</select>
|
||||
<div style={{ width: 92 }}>
|
||||
<GlobalSearchableSelect
|
||||
options={HOUR_VALUES.map(hv => ({ value: hv, label: hv }))}
|
||||
value={h}
|
||||
onChange={v => onChange(`${v ? String(v) : h}:${m}`)}
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontWeight: 700, color: 'var(--text-2)' }}>:</span>
|
||||
<select className="cp-input" style={{ height: 38, width: 72, textAlign: 'center' }}
|
||||
value={m} onChange={e => onChange(`${h}:${e.target.value}`)}>
|
||||
{MINUTE_VALUES.map(mv => <option key={mv} value={mv}>{mv}</option>)}
|
||||
</select>
|
||||
<div style={{ width: 92 }}>
|
||||
<GlobalSearchableSelect
|
||||
options={MINUTE_VALUES.map(mv => ({ value: mv, label: mv }))}
|
||||
value={m}
|
||||
onChange={v => onChange(`${h}:${v ? String(v) : m}`)}
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1494,24 +1502,24 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
||||
<div className={`px-4 py-3 transition-opacity ${meta.online_booking_enabled ? '' : 'opacity-50 pointer-events-none'}`}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">رزرو آنلاین تا</span>
|
||||
<div className="flex items-stretch rounded-lg border border-slate-200 dark:border-gray-700 overflow-hidden bg-white dark:bg-gray-900">
|
||||
<div className="flex items-stretch gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={meta.booking_window_value}
|
||||
disabled={!meta.online_booking_enabled}
|
||||
onChange={(e) => setMeta(m => ({ ...m, booking_window_value: Math.max(1, Number(e.target.value) || 1) }))}
|
||||
className="w-14 text-center text-sm bg-transparent border-0 focus:outline-none focus:ring-0 px-2 py-1.5"
|
||||
className="w-14 text-center text-sm rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 focus:outline-none focus:ring-0 px-2 py-1.5"
|
||||
/>
|
||||
<select
|
||||
value={meta.booking_window_unit}
|
||||
disabled={!meta.online_booking_enabled}
|
||||
onChange={(e) => setMeta(m => ({ ...m, booking_window_unit: e.target.value as 'week' | 'month' }))}
|
||||
className="text-sm bg-slate-50 dark:bg-gray-800 border-0 border-r border-slate-200 dark:border-gray-700 focus:outline-none focus:ring-0 px-2 py-1.5"
|
||||
>
|
||||
<option value="week">هفته</option>
|
||||
<option value="month">ماه</option>
|
||||
</select>
|
||||
<div style={{ width: 110 }}>
|
||||
<GlobalSearchableSelect
|
||||
options={[{ value: 'week', label: 'هفته' }, { value: 'month', label: 'ماه' }]}
|
||||
value={meta.booking_window_unit}
|
||||
onChange={(v) => setMeta(m => ({ ...m, booking_window_unit: (v as 'week' | 'month') }))}
|
||||
isDisabled={!meta.online_booking_enabled}
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">آینده</span>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { PlusIcon, MagnifyingGlassIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
|
||||
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { useInventory } from '../hooks/useInventory';
|
||||
import type { InventoryItem, InventoryPackage, ItemPayload, PackagePayload } from '../hooks/useInventory';
|
||||
import InventoryStatCards from '../components/inventory/InventoryStatCards';
|
||||
@@ -68,11 +69,15 @@ export default function InventoryPage() {
|
||||
style={{ border: 'none', background: 'transparent', flex: 1, padding: 0 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="field" style={{ minWidth: 200, flex: '1 1 260px', maxWidth: 397 }}>
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
<option value="">دستهبندی کالا را انتخاب کنید...</option>
|
||||
{categories.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<div style={{ minWidth: 200, flex: '1 1 260px', maxWidth: 397 }}>
|
||||
<SearchableSelect
|
||||
options={categories.map((c) => ({ value: c, label: c }))}
|
||||
value={category || null}
|
||||
onChange={(v) => setCategory(v ? String(v) : '')}
|
||||
placeholder="دستهبندی کالا را انتخاب کنید..."
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setItemModal({ open: true, editing: null })}>
|
||||
|
||||
@@ -1393,29 +1393,25 @@ function MyPatientsPageInner() {
|
||||
>
|
||||
<div className="field" style={{ flexDirection: "column", alignItems: "stretch", height: "auto", gap: 5, padding: 0, border: "none", background: "none" }}>
|
||||
<label style={{ fontSize: 12.5, fontWeight: 600 }}>بیمه پایه</label>
|
||||
<select
|
||||
className="input"
|
||||
value={baseInsuranceId}
|
||||
onChange={(e) => applyBaseInsurance(e.target.value)}
|
||||
>
|
||||
<option value="">بدون بیمه پایه</option>
|
||||
{baseInsuranceOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
options={baseInsuranceOptions}
|
||||
value={baseInsuranceId || null}
|
||||
onChange={(v) => applyBaseInsurance(v ? String(v) : "")}
|
||||
placeholder="بدون بیمه پایه"
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
<div className="field" style={{ flexDirection: "column", alignItems: "stretch", height: "auto", gap: 5, padding: 0, border: "none", background: "none" }}>
|
||||
<label style={{ fontSize: 12.5, fontWeight: 600 }}>بیمه تکمیلی</label>
|
||||
<select
|
||||
className="input"
|
||||
value={suppInsuranceId}
|
||||
onChange={(e) => applySuppInsurance(e.target.value)}
|
||||
>
|
||||
<option value="">بدون بیمه تکمیلی</option>
|
||||
{suppInsuranceOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
options={suppInsuranceOptions}
|
||||
value={suppInsuranceId || null}
|
||||
onChange={(v) => applySuppInsurance(v ? String(v) : "")}
|
||||
placeholder="بدون بیمه تکمیلی"
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -1460,17 +1456,14 @@ function MyPatientsPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontWeight: 700, fontSize: 13.5, color: "var(--text-2)", borderTop: "1px solid var(--border)", paddingTop: 12 }}>پرداخت و یادداشت</div>
|
||||
<div className="field">
|
||||
<div className="field" style={{ flexDirection: "column", alignItems: "stretch", height: "auto", gap: 5, padding: 0, border: "none", background: "none" }}>
|
||||
<label>روش پرداخت</label>
|
||||
<select {...form.register("payment_method")}>
|
||||
{Object.entries(PAYMENT_LABELS).map(
|
||||
([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
<SearchableSelect
|
||||
options={Object.entries(PAYMENT_LABELS).map(([v, l]) => ({ value: v, label: l }))}
|
||||
value={form.watch("payment_method")}
|
||||
onChange={(v) => form.setValue("payment_method", v as "cash" | "card" | "insurance" | "online" | "pending", { shouldDirty: true })}
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>یادداشت</label>
|
||||
@@ -2026,18 +2019,14 @@ function EditSessionModal({
|
||||
return (
|
||||
<Modal open={!!session} onClose={onClose} title="ویرایش مراجعه">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div className="field">
|
||||
<div className="field" style={{ flexDirection: "column", alignItems: "stretch", height: "auto", gap: 5, padding: 0, border: "none", background: "none" }}>
|
||||
<label>روش پرداخت</label>
|
||||
<select
|
||||
<SearchableSelect
|
||||
options={Object.entries(PAYMENT_LABELS).map(([v, l]) => ({ value: v, label: l }))}
|
||||
value={method}
|
||||
onChange={(e) => setMethod(e.target.value)}
|
||||
>
|
||||
{Object.entries(PAYMENT_LABELS).map(([v, l]) => (
|
||||
<option key={v} value={v}>
|
||||
{l}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setMethod(v ? String(v) : "cash")}
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>یادداشت</label>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MagnifyingGlassIcon, EyeIcon, BanknotesIcon, UserPlusIcon } from '@hero
|
||||
import PageHeader from '../components/ui/PageHeader';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import { formatRial, formatDate, formatNumber, toDate } from '../lib/utils';
|
||||
import {
|
||||
usePayments,
|
||||
@@ -92,17 +93,16 @@ export default function MyPaymentsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="input"
|
||||
style={{ flex: '0 0 auto', width: 160 }}
|
||||
value={status}
|
||||
onChange={(e) => { setStatus(e.target.value); reset(); }}
|
||||
aria-label="وضعیت"
|
||||
>
|
||||
<option value="">همه وضعیتها</option>
|
||||
<option value="paid">پرداخت شده</option>
|
||||
<option value="unsettled">تسویه نشده</option>
|
||||
</select>
|
||||
<div style={{ flex: '0 0 auto', width: 160 }}>
|
||||
<SearchableSelect
|
||||
options={[{ value: 'paid', label: 'پرداخت شده' }, { value: 'unsettled', label: 'تسویه نشده' }]}
|
||||
value={status || null}
|
||||
onChange={(v) => { setStatus(v ? String(v) : ''); reset(); }}
|
||||
placeholder="همه وضعیتها"
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ width: 150 }}>
|
||||
<PersianDateInput value={from} onChange={(v) => { setFrom(v); reset(); }} placeholder="از تاریخ" />
|
||||
|
||||
@@ -11,6 +11,16 @@ vi.mock('../lib/api', () => ({
|
||||
import { api } from '../lib/api';
|
||||
import PatientRecordFormPage from './PatientRecordFormPage';
|
||||
|
||||
/** 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));
|
||||
}
|
||||
|
||||
const post = api.post as ReturnType<typeof vi.fn>;
|
||||
const patch = api.patch as ReturnType<typeof vi.fn>;
|
||||
|
||||
@@ -26,7 +36,7 @@ describe('PatientRecordFormPage (تشکیل پرونده)', () => {
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی را وارد نمایید'), { target: { value: 'بیمار نمونه' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره پرونده'), { target: { value: 'P-1001' } });
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'female' } }); // gender
|
||||
await pickSelect('انتخاب...', 'زن'); // gender = female
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی را وارد نمایید'), { target: { value: '1234567890' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره تماس را وارد نمایید'), { target: { value: '09120000000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
||||
@@ -42,7 +52,7 @@ describe('PatientRecordFormPage (تشکیل پرونده)', () => {
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی را وارد نمایید'), { target: { value: 'ب' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره پرونده'), { target: { value: 'P-1' } });
|
||||
fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'male' } });
|
||||
await pickSelect('انتخاب...', 'مرد');
|
||||
fireEvent.change(screen.getByPlaceholderText('کد ملی را وارد نمایید'), { target: { value: '12' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('شماره تماس را وارد نمایید'), { target: { value: '09120000000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'ثبت اطلاعات' }));
|
||||
|
||||
@@ -10,6 +10,7 @@ import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import type { PatientRecord } from '../types';
|
||||
import PersianDateInput from '../components/ui/PersianDateInput';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
|
||||
const REFERRAL_OPTIONS = ['اینستاگرام', 'معرفی دوستان و آشنایان', 'جستجوی اینترنتی', 'تابلو مطب', 'سایر'];
|
||||
|
||||
@@ -116,11 +117,13 @@ export default function PatientRecordFormPage() {
|
||||
<div className="field"><input {...form.register('record_number')} placeholder="شماره پرونده" /></div>
|
||||
</Field>
|
||||
<Field label="جنسیت" required error={form.formState.errors.gender?.message}>
|
||||
<div className="field"><select {...form.register('gender')} style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit' }}>
|
||||
<option value="">انتخاب...</option>
|
||||
<option value="female">زن</option>
|
||||
<option value="male">مرد</option>
|
||||
</select></div>
|
||||
<SearchableSelect
|
||||
options={[{ value: 'female', label: 'زن' }, { value: 'male', label: 'مرد' }]}
|
||||
value={form.watch('gender') ?? null}
|
||||
onChange={(v) => form.setValue('gender', v as Form['gender'], { shouldValidate: true, shouldDirty: true })}
|
||||
placeholder="انتخاب..."
|
||||
height={38}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="کد ملی" required error={form.formState.errors.national_code?.message}>
|
||||
<div className="field"><input {...form.register('national_code')} inputMode="numeric" placeholder="کد ملی را وارد نمایید" /></div>
|
||||
@@ -132,10 +135,14 @@ export default function PatientRecordFormPage() {
|
||||
<PersianDateInput value={form.watch('birth_date') ?? ''} onChange={(v) => form.setValue('birth_date', v)} enableYearPicker />
|
||||
</Field>
|
||||
<Field label="نحوه آشنایی">
|
||||
<div className="field"><select {...form.register('referral_source')} style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit' }}>
|
||||
<option value="">انتخاب کنید...</option>
|
||||
{REFERRAL_OPTIONS.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select></div>
|
||||
<SearchableSelect
|
||||
options={REFERRAL_OPTIONS.map((o) => ({ value: o, label: o }))}
|
||||
value={form.watch('referral_source') || null}
|
||||
onChange={(v) => form.setValue('referral_source', v ? String(v) : '', { shouldDirty: true })}
|
||||
placeholder="انتخاب کنید..."
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api } from '../lib/api';
|
||||
import type { ApiResponse } from '../lib/api';
|
||||
import { formatRial, formatDate, tomanToRial } from '../lib/utils';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
|
||||
interface WalletBalance { balance_rials: number }
|
||||
interface RepSummary { income: { settlable_rials: number; settled_rials: number; pending_rials: number } }
|
||||
@@ -130,15 +131,16 @@ export default function RepresentationSettlementPage() {
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
style={{ width: 200, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 13.5, boxSizing: 'border-box' }}
|
||||
/>
|
||||
<select
|
||||
value={ibanId} onChange={(e) => setIbanId(e.target.value)} dir="ltr"
|
||||
style={{ width: 320, height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)', fontSize: 12.5, boxSizing: 'border-box' }}
|
||||
>
|
||||
<option value="">انتخاب شماره شبا...</option>
|
||||
{ibans.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.iban}{b.bank_name ? ` — ${b.bank_name}` : ''}</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ width: 320 }}>
|
||||
<SearchableSelect
|
||||
options={ibans.map((b) => ({ value: String(b.id), label: `${b.iban}${b.bank_name ? ` — ${b.bank_name}` : ''}` }))}
|
||||
value={ibanId || null}
|
||||
onChange={(v) => setIbanId(v ? String(v) : '')}
|
||||
placeholder="انتخاب شماره شبا..."
|
||||
isClearable
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn primary" onClick={submit} disabled={createMut.isPending}>
|
||||
{createMut.isPending ? 'در حال ثبت...' : 'ثبت درخواست'}
|
||||
</button>
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Appointment } from '../types';
|
||||
import { formatDate } from '../lib/utils';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import NewAppointmentDrawer from '../components/NewAppointmentDrawer';
|
||||
import { AppointmentInfoModal, TransferReserveModal } from '../components/AppointmentActions';
|
||||
@@ -118,11 +119,16 @@ export default function ReserveAppointmentsPage() {
|
||||
<h1 style={{ fontSize: 17, fontWeight: 800 }}>نوبت های رزرو شده</h1>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{isClinic && (
|
||||
<select aria-label="پزشک" value={doctorUuid} onChange={e => setDoctorUuid(e.target.value)}
|
||||
style={{ height: 34, borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, fontFamily: 'inherit', padding: '0 10px' }}>
|
||||
<option value="">انتخاب پزشک...</option>
|
||||
{clinicDoctors.map(d => <option key={d.uuid} value={d.uuid}>{d.name}</option>)}
|
||||
</select>
|
||||
<div style={{ minWidth: 180 }}>
|
||||
<SearchableSelect
|
||||
options={clinicDoctors.map(d => ({ value: d.uuid, label: d.name }))}
|
||||
value={doctorUuid || null}
|
||||
onChange={v => setDoctorUuid(v ? String(v) : '')}
|
||||
placeholder="انتخاب پزشک..."
|
||||
isClearable
|
||||
height={34}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{primaryRole !== 'representation' && (
|
||||
<button className="btn primary sm" disabled={!doctorUuid}
|
||||
|
||||
Reference in New Issue
Block a user