From 960ff1ab29e365bb2bd9a5396a885a1bb1eac032 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 12 Jun 2026 20:43:47 +0330 Subject: [PATCH] feat: enhance DoctorFormPage with searchable specialties and improved UI components - Refactored DoctorFormPage to use Controller from react-hook-form for better form handling. - Added a new Field component for consistent input styling and error handling. - Implemented a SpecialtyPicker component with improved selection logic for specialties. - Updated the layout and styling of the form sections for better user experience. - Integrated SearchableSelect for selecting specialties and roles in DoctorsPage and UsersPage. - Added createClinic API endpoint to handle clinic creation with validation for mobile and name fields. --- .../admin/components/ui/SearchableSelect.tsx | 78 ++- assets/admin/pages/AppointmentDetailPage.tsx | 20 +- assets/admin/pages/AppointmentsPage.tsx | 25 +- assets/admin/pages/BlogFormPage.tsx | 14 +- assets/admin/pages/DoctorDetailPage.tsx | 92 ++-- assets/admin/pages/DoctorFormPage.tsx | 514 +++++++++++------- assets/admin/pages/DoctorsPage.tsx | 17 +- .../admin/pages/RepresentationDetailPage.tsx | 18 +- assets/admin/pages/UsersPage.tsx | 14 +- src/Admin/Controller/AdminApiController.php | 32 ++ 10 files changed, 480 insertions(+), 344 deletions(-) diff --git a/assets/admin/components/ui/SearchableSelect.tsx b/assets/admin/components/ui/SearchableSelect.tsx index 6da9ef4c..ccf4d25e 100644 --- a/assets/admin/components/ui/SearchableSelect.tsx +++ b/assets/admin/components/ui/SearchableSelect.tsx @@ -17,6 +17,7 @@ interface Props { isClearable?: boolean; noOptionsMessage?: string; inputId?: string; + height?: number; } export default function SearchableSelect({ @@ -29,6 +30,7 @@ export default function SearchableSelect({ isClearable, noOptionsMessage = 'موردی یافت نشد', inputId, + height = 42, }: Props) { const darkMode = useUiStore((s) => s.darkMode); @@ -40,64 +42,60 @@ export default function SearchableSelect({ const styles: StylesConfig> = { control: (base, state) => ({ ...base, - background: darkMode ? '#111827' : '#ffffff', - borderColor: state.isFocused ? '#7c3aed' : darkMode ? '#4b5563' : '#cbd5e1', - boxShadow: state.isFocused ? '0 0 0 2px rgba(124,58,237,0.25)' : 'none', - borderRadius: '0.75rem', - minHeight: '2.75rem', - fontSize: '0.875rem', + background: darkMode ? 'var(--surface)' : 'var(--surface)', + borderColor: state.isFocused ? 'var(--primary)' : 'var(--border)', + boxShadow: state.isFocused ? '0 0 0 4px var(--ring)' : 'none', + borderRadius: 'var(--r-sm)', + minHeight: height, + fontSize: 14, + cursor: 'pointer', '&:hover': { - borderColor: state.isFocused ? '#7c3aed' : darkMode ? '#6b7280' : '#94a3b8', + borderColor: state.isFocused ? 'var(--primary)' : 'var(--border-2)', }, }), + valueContainer: (base) => ({ ...base, padding: '0 14px' }), menu: (base) => ({ ...base, - background: darkMode ? '#1f2937' : '#ffffff', - border: `1px solid ${darkMode ? '#374151' : '#e2e8f0'}`, - boxShadow: '0 8px 24px rgba(0,0,0,0.15)', - borderRadius: '0.75rem', - overflow: 'hidden', + background: 'var(--surface)', + border: '1px solid var(--border)', + boxShadow: 'var(--shadow-lg)', + borderRadius: 'var(--r)', + overflow: 'hidden', + marginTop: 4, }), menuPortal: (base) => ({ ...base, zIndex: 9999 }), option: (base, state) => ({ ...base, background: state.isSelected - ? '#7c3aed' + ? 'var(--primary-soft2)' : state.isFocused - ? darkMode ? '#374151' : '#f1f5f9' + ? 'var(--surface-2)' : 'transparent', - color: state.isSelected ? '#ffffff' : darkMode ? '#e5e7eb' : '#1e293b', - fontSize: '0.875rem', - cursor: 'pointer', + color: state.isSelected ? 'var(--primary-700)' : 'var(--text)', + fontWeight: state.isSelected ? 700 : 400, + fontSize: 14, + cursor: 'pointer', + padding: '9px 14px', }), - singleValue: (base) => ({ ...base, color: darkMode ? '#f3f4f6' : '#1e293b' }), - input: (base) => ({ ...base, color: darkMode ? '#f3f4f6' : '#1e293b' }), - placeholder: (base) => ({ ...base, color: darkMode ? '#6b7280' : '#94a3b8' }), - indicatorSeparator: (base) => ({ + singleValue: (base) => ({ ...base, color: 'var(--text)' }), + input: (base) => ({ ...base, color: 'var(--text)', margin: 0, padding: 0 }), + placeholder: (base) => ({ ...base, color: 'var(--text-3)' }), + indicatorSeparator: (base) => ({ ...base, background: 'var(--border)' }), + dropdownIndicator: (base) => ({ ...base, - background: darkMode ? '#374151' : '#e2e8f0', - }), - dropdownIndicator: (base) => ({ - ...base, - color: darkMode ? '#6b7280' : '#94a3b8', - '&:hover': { color: darkMode ? '#9ca3af' : '#64748b' }, + color: 'var(--text-3)', + padding: '0 10px', + '&:hover': { color: 'var(--text-2)' }, }), clearIndicator: (base) => ({ ...base, - color: darkMode ? '#6b7280' : '#94a3b8', - '&:hover': { color: '#ef4444' }, - }), - loadingIndicator: (base) => ({ ...base, color: '#7c3aed' }), - noOptionsMessage: (base) => ({ - ...base, - color: darkMode ? '#6b7280' : '#94a3b8', - fontSize: '0.875rem', - }), - loadingMessage: (base) => ({ - ...base, - color: darkMode ? '#6b7280' : '#94a3b8', - fontSize: '0.875rem', + color: 'var(--text-3)', + padding: '0 6px', + '&:hover': { color: 'var(--danger)' }, }), + loadingIndicator: (base) => ({ ...base, color: 'var(--primary)' }), + noOptionsMessage: (base) => ({ ...base, color: 'var(--text-3)', fontSize: 13 }), + loadingMessage: (base) => ({ ...base, color: 'var(--text-3)', fontSize: 13 }), }; return ( diff --git a/assets/admin/pages/AppointmentDetailPage.tsx b/assets/admin/pages/AppointmentDetailPage.tsx index ad3df834..9e3f42bf 100644 --- a/assets/admin/pages/AppointmentDetailPage.tsx +++ b/assets/admin/pages/AppointmentDetailPage.tsx @@ -10,6 +10,7 @@ import { formatDate, formatDateTime } from '../lib/utils'; import PageHeader from '../components/ui/PageHeader'; import StatusBadge from '../components/ui/StatusBadge'; import ConfirmDialog from '../components/ui/ConfirmDialog'; +import SearchableSelect from '../components/ui/SearchableSelect'; const ALL_STATUSES: { value: AppointmentStatus; label: string }[] = [ { value: 'pending', label: 'رزرو شده' }, @@ -112,16 +113,15 @@ export default function AppointmentDetailPage() {
- +
+ ({ value: s.value, label: s.label }))} + value={newStatus || null} + onChange={(v) => setNewStatus(v ? String(v) : '')} + placeholder="انتخاب وضعیت..." + isClearable + /> +
+ + {label} + ); })}
)} -
+
{/* Parents column */} -
-
- گروه تخصص +
+
+ گروه تخصصی
- {parents.map(p => ( - - ))} + {parents.map(p => { + const hasChildren = (childMap[p.id]?.length ?? 0) > 0; + const childSel = hasChildren ? selectedChildOfParent(p.id) : null; + const rootSel = !hasChildren && isRootSelected(p.id); + const isMarked = childSel !== null || rootSel; + const isActive = activeParentId === p.id; + const hasSelection = selected.length > 0; + const isDisabled = hasSelection && !isMarked; + return ( + + ); + })}
{/* Children column */} -
+
{activeParentId === null ? ( -
- یک گروه تخصص را از سمت راست انتخاب کنید +
+ + یک گروه تخصصی انتخاب کنید
- ) : children.length === 0 ? ( -
+ ) : activeChildren.length === 0 ? ( +
زیرمجموعه‌ای یافت نشد
) : ( <> -
- انتخاب تخصص +
+ انتخاب تخصص — یک مورد
- {children.map(s => ( - - ))} + {activeChildren.map(s => { + const checked = selected.includes(s.id); + return ( + + ); + })} )}
@@ -141,11 +249,12 @@ function SpecialtyPicker({ selected, onChange, specialties }: { ); } -// ── Page ────────────────────────────────────────────────────────────────── +// ── Page ────────────────────────────────────────────────────────────────────── export default function DoctorFormPage() { const navigate = useNavigate(); const [selectedSpecialties, setSelectedSpecialties] = useState([]); + const [gender, setGender] = useState<'man' | 'woman' | ''>(''); const specialtiesQ = useQuery({ queryKey: ['specialties-list'], @@ -155,12 +264,11 @@ export default function DoctorFormPage() { const specialties: SpecialtyOption[] = useMemo( () => (specialtiesQ.data?.data as any)?.data ?? specialtiesQ.data?.data ?? [], - [specialtiesQ.data] + [specialtiesQ.data], ); - const { register, handleSubmit, formState: { errors } } = useForm({ + const { register, handleSubmit, control, formState: { errors } } = useForm({ resolver: zodResolver(schema), - defaultValues: { specialties: [] }, }); const createMut = useMutation({ @@ -168,7 +276,7 @@ export default function DoctorFormPage() { api.post>('/api/v1/admin/doctors', { mobile: values.mobile, name: values.name, - gender: values.gender || undefined, + gender: gender || undefined, degree: values.degree || undefined, medical_system_code: values.medical_system_code || undefined, info: values.info || undefined, @@ -183,158 +291,144 @@ export default function DoctorFormPage() { onError: (e: Error) => toast.error(e.message), }); - const onSubmit = (values: FormValues) => createMut.mutate({ ...values, specialties: selectedSpecialties }); + const onSubmit = (values: FormValues) => createMut.mutate(values); return ( -
+
{/* Breadcrumb */} -
- / - افزودن پزشک جدید + افزودن پزشک جدید
- {/* Header card */} -
-
- -
-
-

افزودن پزشک جدید

-

- اطلاعات پزشک را وارد کنید. اگر شماره موبایل از قبل در سیستم باشد، پروفایل پزشک به همان کاربر متصل می‌شود. -

-
-
+
+
- {/* Form */} - - - {/* Account info */} -
-

- اطلاعات حساب -

- -
- {/* Mobile */} -
- - - {errors.mobile &&

{errors.mobile.message}

} -

- اگر کاربر با این شماره وجود داشته باشد، پروفایل پزشک به آن متصل می‌شود -

-
- - {/* Name */} -
- - - {errors.name &&

{errors.name.message}

} -
-
-
- - {/* Professional info */} -
-

- اطلاعات حرفه‌ای -

- -
- {/* Gender */} -
- - -
- - {/* Degree */} -
- - -
- - {/* Medical system code */} -
- - + {/* ── Section: اطلاعات حساب ──────────────────────────────── */} +
+ } title="اطلاعات حساب" /> +
+ + + + + +
- {/* Bio */} -
- -