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:
hamed
2026-07-16 08:56:59 +03:30
parent 7b8a5c2775
commit b1b99903ec
21 changed files with 571 additions and 288 deletions
@@ -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' } });
+44 -67
View File
@@ -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>