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.
This commit is contained in:
hamed
2026-06-12 20:43:47 +03:30
parent 9e8064e101
commit 960ff1ab29
10 changed files with 480 additions and 344 deletions
+38 -40
View File
@@ -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<SelectOption, false, GroupBase<SelectOption>> = {
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 (
+10 -10
View File
@@ -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() {
<div className="mt-6">
<label className="cp-label mb-2">تغییر وضعیت:</label>
<div className="flex gap-2">
<select
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
className="cp-input flex-1"
>
<option value="">انتخاب وضعیت...</option>
{ALL_STATUSES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
<div style={{ flex: 1 }}>
<SearchableSelect
options={ALL_STATUSES.map(s => ({ value: s.value, label: s.label }))}
value={newStatus || null}
onChange={(v) => setNewStatus(v ? String(v) : '')}
placeholder="انتخاب وضعیت..."
isClearable
/>
</div>
<button
onClick={() => newStatus && statusMutation.mutate(newStatus)}
disabled={!newStatus || statusMutation.isPending}
+11 -14
View File
@@ -12,6 +12,7 @@ import { formatDate, toGregorianDate } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
import AppointmentStatusDropdown from '../components/ui/AppointmentStatusDropdown';
import PersianCalendar from '../components/ui/PersianCalendar';
import SearchableSelect from '../components/ui/SearchableSelect';
const EMPTY_ARR: Appointment[] = [];
@@ -704,20 +705,16 @@ export default function AppointmentsPage() {
{/* Doctor selector (admin / clinic) */}
{!isDoctor && (
<select
value={selectedDoctorUuid}
onChange={e => setSelectedDoctorUuid(e.target.value)}
style={{
height: 36, padding: '0 10px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', background: 'var(--surface)',
fontSize: 13, color: 'var(--text)', minWidth: 180, cursor: 'pointer',
}}
>
<option value="">پرسنل را انتخاب کنید...</option>
{doctors.map(d => (
<option key={d.uuid} value={d.uuid}>{d.name}</option>
))}
</select>
<div style={{ minWidth: 200 }}>
<SearchableSelect
options={doctors.map(d => ({ value: d.uuid, label: d.name }))}
value={selectedDoctorUuid || null}
onChange={(v) => setSelectedDoctorUuid(v ? String(v) : '')}
placeholder="انتخاب پزشک..."
isClearable
height={36}
/>
</div>
)}
<div style={{ flex: 1 }} />
+9 -5
View File
@@ -9,6 +9,7 @@ import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { Blog } from '../types';
import PageHeader from '../components/ui/PageHeader';
import SearchableSelect from '../components/ui/SearchableSelect';
const schema = z.object({
title: z.string().min(3, 'عنوان الزامی است'),
@@ -134,11 +135,14 @@ export default function BlogFormPage() {
control={control}
name="status"
render={({ field }) => (
<select {...field}
className="cp-input h-11">
<option value="draft">پیشنویس</option>
<option value="published">منتشر</option>
</select>
<SearchableSelect
options={[
{ value: 'draft', label: 'پیش‌نویس' },
{ value: 'published', label: 'منتشر شده' },
]}
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? 'draft')}
/>
)}
/>
</div>
+50 -42
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
@@ -25,6 +25,7 @@ import { formatNumber } from '../lib/utils';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import NotificationMobileCard from '../components/ui/NotificationMobileCard';
import GlobalSearchableSelect from '../components/ui/SearchableSelect';
// Fix leaflet default marker icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -996,10 +997,14 @@ function SlotEditor({ slots, onChange }: {
<span className="text-xs text-slate-500 dark:text-slate-400 shrink-0">تا</span>
<input type="time" value={slot.end} onChange={e => update(i, 'end', e.target.value)}
className="cp-input h-8 w-28 text-sm font-mono text-center px-2" />
<select value={slot.duration} onChange={e => update(i, 'duration', Number(e.target.value))}
className="cp-select h-8 text-sm w-24">
{DURATION_OPTS.map(d => <option key={d} value={d}>{d} دقیقه</option>)}
</select>
<div style={{ width: 110 }}>
<GlobalSearchableSelect
options={DURATION_OPTS.map(d => ({ value: d, label: `${d} دقیقه` }))}
value={slot.duration}
onChange={(v) => update(i, 'duration', Number(v))}
height={32}
/>
</div>
<button type="button" onClick={() => remove(i)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors shrink-0">
<XMarkIcon className="w-3.5 h-3.5" />
@@ -1052,37 +1057,30 @@ function SessionEditor({ session, onChange, onRemove, addresses }: {
<div style={{ padding: '0 16px 14px', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>بازه هر نوبت</div>
<div className="field">
<select value={session.duration_per_patient}
onChange={e => upd('duration_per_patient', Number(e.target.value))}>
{DURATION_OPTS.map(d => <option key={d} value={d}>{d} دقیقه</option>)}
</select>
</div>
<GlobalSearchableSelect
options={DURATION_OPTS.map(d => ({ value: d, label: `${d} دقیقه` }))}
value={session.duration_per_patient}
onChange={(v) => upd('duration_per_patient', Number(v))}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>مکان نوبت</div>
<div className="field">
<select value={session.location_id ?? ''}
onChange={e => upd('location_id', e.target.value ? Number(e.target.value) : null)}>
<option value="">انتخاب کنید</option>
{addresses.filter(a => a.type === 'personal').length > 0 && (
<optgroup label="مطب شخصی">
{addresses.filter(a => a.type === 'personal').map(a => (
<option key={a.id} value={a.id}>{a.name ?? a.address ?? `مطب ${a.id}`}</option>
))}
</optgroup>
)}
{addresses.filter(a => a.type === 'clinic').length > 0 && (
<optgroup label="کلینیک‌ها">
{addresses.filter(a => a.type === 'clinic').map(a => (
<option key={a.id} value={a.id}>
{a.clinic_name ? `${a.clinic_name}${a.name ? `${a.name}` : ''}` : (a.name ?? a.address ?? `کلینیک ${a.id}`)}
</option>
))}
</optgroup>
)}
</select>
</div>
<GlobalSearchableSelect
options={[
...addresses.filter(a => a.type === 'personal').map(a => ({
value: Number(a.id),
label: `🏥 ${a.name ?? a.address ?? `مطب ${a.id}`}`,
})),
...addresses.filter(a => a.type === 'clinic').map(a => ({
value: Number(a.id),
label: `🏨 ${a.clinic_name ? `${a.clinic_name}${a.name ? `${a.name}` : ''}` : (a.name ?? a.address ?? `کلینیک ${a.id}`)}`,
})),
]}
value={session.location_id ?? null}
onChange={(v) => upd('location_id', v ? Number(v) : null)}
placeholder="انتخاب مکان"
isClearable
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 5 }}>حداکثر نوبت</div>
@@ -1850,7 +1848,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
// ── Edit form ──
const { register, handleSubmit, reset, watch, setValue } = useForm<EditForm>({
const { register, handleSubmit, reset, watch, setValue, control: editControl } = useForm<EditForm>({
resolver: zodResolver(editSchema),
});
const watchedSpecialties = (watch('specialties') ?? []) as number[];
@@ -2279,17 +2277,27 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">جنسیت</label>
<select className="cp-select h-11" {...register('gender')}>
<option value="">انتخاب کنید</option>
{GENDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<Controller name="gender" control={editControl} render={({ field }) => (
<GlobalSearchableSelect
options={GENDER_OPTIONS}
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? '')}
placeholder="انتخاب کنید"
isClearable
/>
)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">درجه تحصیلی</label>
<select className="cp-select h-11" {...register('degree')}>
<option value="">انتخاب کنید</option>
{DEGREE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<Controller name="degree" control={editControl} render={({ field }) => (
<GlobalSearchableSelect
options={DEGREE_OPTIONS}
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? '')}
placeholder="انتخاب کنید"
isClearable
/>
)} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">کد نظام پزشکی</label>
+304 -210
View File
@@ -1,20 +1,24 @@
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowRightIcon, UserPlusIcon } from '@heroicons/react/24/outline';
import {
ArrowRightIcon, UserPlusIcon, PhoneIcon,
IdentificationIcon, AcademicCapIcon, DocumentTextIcon,
HeartIcon, ChevronLeftIcon, CheckIcon,
} from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatNumber } from '../lib/utils';
import SearchableSelect from '../components/ui/SearchableSelect';
// ── Types ────────────────────────────────────────────────────────────────
// ── Types ────────────────────────────────────────────────────────────────────
interface SpecialtyOption { id: number; uuid: string; name: string; parent_id: number | null }
// ── Schema ───────────────────────────────────────────────────────────────
// ── Schema ───────────────────────────────────────────────────────────────────
const schema = z.object({
mobile: z.string().min(10, 'شماره موبایل معتبر نیست').max(15),
@@ -23,25 +27,49 @@ const schema = z.object({
degree: z.string().optional().or(z.literal('')),
medical_system_code: z.string().max(30).optional().or(z.literal('')),
info: z.string().max(2000).optional().or(z.literal('')),
specialties: z.array(z.number()).optional(),
});
type FormValues = z.infer<typeof schema>;
// ── Constants ─────────────────────────────────────────────────────────────
// ── Constants ─────────────────────────────────────────────────────────────────
const DEGREE_OPTIONS = [
{ value: 'general', label: 'عمومی' },
{ value: 'general', label: 'پزشک عمومی' },
{ value: 'specialist', label: 'متخصص' },
{ value: 'expert', label: 'فوق تخصص' },
{ value: 'subspecialistplus', label: 'فلوشیپ' },
];
const GENDER_OPTIONS = [
{ value: 'man', label: 'مرد' },
{ value: 'woman', label: 'زن' },
{ value: 'man', label: 'مرد', icon: '♂' },
{ value: 'woman', label: 'زن', icon: '♀' },
];
// ── Specialty Picker ──────────────────────────────────────────────────────
// ── Field ─────────────────────────────────────────────────────────────────────
function Field({ label, required, error, hint, children }: {
label: string; required?: boolean; error?: string; hint?: string; children: React.ReactNode;
}) {
return (
<div>
<label className="cp-label">
{label}{required && <span style={{ color: 'var(--danger)', marginRight: 2 }}>*</span>}
</label>
{children}
{hint && !error && <p style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 5 }}>{hint}</p>}
{error && <p style={{ fontSize: 12, color: 'var(--danger)', marginTop: 5 }}>{error}</p>}
</div>
);
}
// ── Specialty Picker ──────────────────────────────────────────────────────────
//
// منطق انتخاب:
// • تخصص با فرزند → کلیک روی فرزند: parent_id + child_id هر دو ذخیره می‌شوند
// در هر گروه فقط یک فرزند قابل انتخاب است (جایگزین می‌شود)
// • تخصص بدون فرزند → فقط خودش انتخاب می‌شود
// • حذف chip: parent + child هر دو پاک می‌شوند
interface SelectedEntry { parentId: number | null; childId: number }
function SpecialtyPicker({ selected, onChange, specialties }: {
selected: number[]; onChange: (ids: number[]) => void;
@@ -49,90 +77,170 @@ function SpecialtyPicker({ selected, onChange, specialties }: {
}) {
const [activeParentId, setActiveParentId] = useState<number | null>(null);
const parents = useMemo(
() => specialties.filter(s => s.parent_id === null),
[specialties]
);
const parents = useMemo(() => specialties.filter(s => s.parent_id === null), [specialties]);
const childMap = useMemo(() => {
const m: Record<number, SpecialtyOption[]> = {};
specialties.forEach(s => {
if (s.parent_id !== null) {
if (!m[s.parent_id]) m[s.parent_id] = [];
m[s.parent_id].push(s);
}
});
return m;
}, [specialties]);
const children = useMemo(
() => activeParentId !== null ? specialties.filter(s => s.parent_id === activeParentId) : [],
[specialties, activeParentId]
);
const activeChildren = activeParentId !== null ? (childMap[activeParentId] ?? []) : [];
const toggle = (id: number) =>
onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
// برای هر گروه، child انتخاب‌شده را پیدا می‌کند
const selectedChildOfParent = (parentId: number): number | null => {
const kids = childMap[parentId] ?? [];
return kids.find(k => selected.includes(k.id))?.id ?? null;
};
// آیا یک والد بدون فرزند انتخاب شده (مستقیم)
const isRootSelected = (parentId: number): boolean =>
!(childMap[parentId]?.length) && selected.includes(parentId);
const selectChild = (child: SpecialtyOption) => {
const parentId = child.parent_id!;
if (selected.includes(child.id)) {
onChange([]);
} else {
onChange([parentId, child.id]);
}
};
const selectRoot = (root: SpecialtyOption) => {
if (selected.includes(root.id)) {
onChange([]);
} else {
setActiveParentId(null);
onChange([root.id]);
}
};
const removeEntry = () => { onChange([]); setActiveParentId(null); };
// ساخت chip ها: از selected فقط child‌ها + root‌های مستقیم را نمایش بده
const chips: SelectedEntry[] = useMemo(() => {
return selected
.map(id => {
const s = specialties.find(x => x.id === id);
if (!s) return null;
if (s.parent_id !== null) return { parentId: s.parent_id, childId: s.id };
// اگر parent است و child‌ای از آن انتخاب نشده → root مستقیم
const hasSelectedChild = (childMap[s.id] ?? []).some(k => selected.includes(k.id));
if (!hasSelectedChild && !(childMap[s.id]?.length)) return { parentId: null, childId: s.id };
return null; // parent‌هایی که فرزند دارند و فرزندشان انتخاب شده: در chip فرزند نمایش داده می‌شوند
})
.filter(Boolean) as SelectedEntry[];
}, [selected, specialties, childMap]);
return (
<div className="border border-slate-200 dark:border-gray-700 rounded-xl overflow-hidden">
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
{/* Selected chips */}
{selected.length > 0 && (
<div className="px-3 py-2 border-b border-slate-100 dark:border-gray-700 flex flex-wrap gap-1 bg-slate-50 dark:bg-gray-800/40">
{selected.map(id => {
const s = specialties.find(x => x.id === id);
if (!s) return null;
{chips.length > 0 && (
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--border)', background: 'var(--surface-2)', display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{chips.map(({ parentId, childId }) => {
const child = specialties.find(x => x.id === childId);
const parent = parentId !== null ? specialties.find(x => x.id === parentId) : null;
if (!child) return null;
const label = parent ? `${parent.name}${child.name}` : child.name;
return (
<span key={id} className="inline-flex items-center gap-1 text-xs px-2.5 py-0.5 rounded-full bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300">
{s.name}
<button type="button" onClick={() => toggle(id)} className="hover:text-primary-900 dark:hover:text-primary-100 leading-none">×</button>
<span key={childId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, padding: '3px 10px 3px 6px', borderRadius: 999, background: 'var(--primary-soft2)', color: 'var(--primary-700)', fontWeight: 600 }}>
{label}
<button type="button" onClick={() => removeEntry()}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 16, height: 16, borderRadius: '50%', background: 'var(--primary-soft)', border: 'none', cursor: 'pointer', color: 'var(--primary-600)', padding: 0, fontSize: 12, fontWeight: 700 }}>×</button>
</span>
);
})}
</div>
)}
<div className="flex" style={{ minHeight: 200 }}>
<div style={{ display: 'flex', minHeight: 240 }}>
{/* Parents column */}
<div className="w-1/2 border-l border-slate-100 dark:border-gray-700 overflow-y-auto max-h-64">
<div className="px-3 py-1.5 text-xs font-semibold text-slate-400 dark:text-slate-500 bg-slate-50 dark:bg-gray-800/60 border-b border-slate-100 dark:border-gray-700">
گروه تخصص
<div style={{ width: '45%', borderLeft: '1px solid var(--border)', overflowY: 'auto', maxHeight: 300 }}>
<div style={{ padding: '8px 14px', fontSize: 11, fontWeight: 700, color: 'var(--text-3)', background: 'var(--surface-2)', borderBottom: '1px solid var(--border)', letterSpacing: '.2px' }}>
گروه تخصصی
</div>
{parents.map(p => (
<button
key={p.id}
type="button"
onClick={() => setActiveParentId(p.id)}
className="w-full text-right flex items-center gap-2 px-3 py-2 text-sm transition-colors"
style={{
background: activeParentId === p.id ? 'var(--primary-50, #eff6ff)' : 'transparent',
color: activeParentId === p.id ? 'var(--primary-700, #1d4ed8)' : undefined,
fontWeight: activeParentId === p.id ? 600 : 400,
}}
>
<span className="flex-1">{p.name}</span>
{specialties.some(s => s.parent_id === p.id && selected.includes(s.id)) && (
<span className="w-2 h-2 rounded-full bg-primary-500 shrink-0" />
)}
<span className="text-slate-300 dark:text-slate-600 text-xs"></span>
</button>
))}
{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 (
<button
key={p.id}
type="button"
disabled={isDisabled}
onClick={() => hasChildren ? setActiveParentId(p.id) : selectRoot(p)}
style={{
width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px', fontSize: 13, border: 'none',
cursor: isDisabled ? 'not-allowed' : 'pointer',
opacity: isDisabled ? 0.35 : 1,
background: isActive ? 'var(--primary-soft)' : rootSel ? 'var(--primary-soft)' : 'transparent',
color: (isActive || rootSel) ? 'var(--primary-700)' : 'var(--text)',
fontWeight: (isActive || rootSel) ? 700 : 400,
borderRight: (isActive || rootSel) ? '3px solid var(--primary)' : '3px solid transparent',
transition: 'all .14s',
}}
>
<span style={{ flex: 1, lineHeight: 1.4 }}>{p.name}</span>
{isMarked && !rootSel && (
<span style={{ fontSize: 11, fontWeight: 700, background: 'var(--primary)', color: 'var(--on-primary)', borderRadius: 999, padding: '1px 7px', minWidth: 20, textAlign: 'center' }}>
</span>
)}
{rootSel && <CheckIcon style={{ width: 14, height: 14, color: 'var(--primary)', flexShrink: 0 }} />}
{hasChildren && <ChevronLeftIcon style={{ width: 14, height: 14, color: 'var(--text-3)', flexShrink: 0 }} />}
</button>
);
})}
</div>
{/* Children column */}
<div className="w-1/2 overflow-y-auto max-h-64">
<div style={{ flex: 1, overflowY: 'auto', maxHeight: 300 }}>
{activeParentId === null ? (
<div className="flex items-center justify-center h-full text-xs text-slate-400 dark:text-slate-500 text-center px-4">
یک گروه تخصص را از سمت راست انتخاب کنید
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-3)', fontSize: 13, gap: 8, padding: 24 }}>
<HeartIcon style={{ width: 32, height: 32, opacity: .3 }} />
<span>یک گروه تخصصی انتخاب کنید</span>
</div>
) : children.length === 0 ? (
<div className="flex items-center justify-center h-full text-xs text-slate-400 dark:text-slate-500">
) : activeChildren.length === 0 ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-3)', fontSize: 13 }}>
زیرمجموعهای یافت نشد
</div>
) : (
<>
<div className="px-3 py-1.5 text-xs font-semibold text-slate-400 dark:text-slate-500 bg-slate-50 dark:bg-gray-800/60 border-b border-slate-100 dark:border-gray-700">
انتخاب تخصص
<div style={{ padding: '8px 14px', fontSize: 11, fontWeight: 700, color: 'var(--text-3)', background: 'var(--surface-2)', borderBottom: '1px solid var(--border)', letterSpacing: '.2px' }}>
انتخاب تخصص یک مورد
</div>
{children.map(s => (
<label key={s.id} className="flex items-center gap-2.5 px-3 py-2 hover:bg-slate-50 dark:hover:bg-gray-800 cursor-pointer">
<input
type="checkbox"
checked={selected.includes(s.id)}
onChange={() => toggle(s.id)}
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 shrink-0"
/>
<span className="text-sm text-slate-700 dark:text-slate-300">{s.name}</span>
</label>
))}
{activeChildren.map(s => {
const checked = selected.includes(s.id);
return (
<button
key={s.id}
type="button"
onClick={() => selectChild(s)}
style={{
width: '100%', textAlign: 'right', display: 'flex', alignItems: 'center', gap: 10,
padding: '10px 14px', border: 'none', cursor: 'pointer',
background: checked ? 'var(--primary-soft)' : 'transparent',
borderBottom: '1px solid var(--border)',
transition: 'background .12s',
}}
>
<div style={{ width: 18, height: 18, borderRadius: '50%', border: `2px solid ${checked ? 'var(--primary)' : 'var(--border-2)'}`, background: checked ? 'var(--primary)' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'all .12s' }}>
{checked && <div style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--on-primary)' }} />}
</div>
<span style={{ fontSize: 13, color: checked ? 'var(--primary-700)' : 'var(--text)', fontWeight: checked ? 600 : 400 }}>{s.name}</span>
</button>
);
})}
</>
)}
</div>
@@ -141,11 +249,12 @@ function SpecialtyPicker({ selected, onChange, specialties }: {
);
}
// ── Page ──────────────────────────────────────────────────────────────────
// ── Page ──────────────────────────────────────────────────────────────────────
export default function DoctorFormPage() {
const navigate = useNavigate();
const [selectedSpecialties, setSelectedSpecialties] = useState<number[]>([]);
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<FormValues>({
const { register, handleSubmit, control, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { specialties: [] },
});
const createMut = useMutation({
@@ -168,7 +276,7 @@ export default function DoctorFormPage() {
api.post<ApiResponse<{ uuid: string }>>('/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 (
<div className="animate-slide-up max-w-3xl mx-auto space-y-5">
<div style={{ maxWidth: 680, margin: '0 auto' }} className="animate-slide-up">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<button onClick={() => navigate('/admin/doctors')}
className="flex items-center gap-1.5 hover:text-slate-800 dark:hover:text-slate-100 transition-colors">
<ArrowRightIcon className="w-4 h-4" />پزشکان
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: 'var(--text-3)', marginBottom: 20 }}>
<button onClick={() => navigate('/admin/doctors')} style={{ display: 'flex', alignItems: 'center', gap: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-3)', fontSize: 13, padding: 0 }}>
<ArrowRightIcon style={{ width: 14, height: 14 }} />
پزشکان
</button>
<span>/</span>
<span className="text-slate-700 dark:text-slate-300 font-medium">افزودن پزشک جدید</span>
<span style={{ color: 'var(--text-2)', fontWeight: 600 }}>افزودن پزشک جدید</span>
</div>
{/* Header card */}
<div className="cp-card p-6 flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center shadow-lg">
<UserPlusIcon className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-slate-900 dark:text-slate-50">افزودن پزشک جدید</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
اطلاعات پزشک را وارد کنید. اگر شماره موبایل از قبل در سیستم باشد، پروفایل پزشک به همان کاربر متصل میشود.
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Form */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
{/* Account info */}
<div className="cp-card p-6 space-y-4">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 border-b border-slate-100 dark:border-gray-700 pb-3">
اطلاعات حساب
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Mobile */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
شماره موبایل <span className="text-red-500">*</span>
</label>
<input type="text" dir="ltr"
className={`cp-input text-left ${errors.mobile ? 'border-red-400 focus:ring-red-400' : ''}`}
placeholder="09xxxxxxxxx"
{...register('mobile')} />
{errors.mobile && <p className="text-xs text-red-500 mt-1">{errors.mobile.message}</p>}
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
اگر کاربر با این شماره وجود داشته باشد، پروفایل پزشک به آن متصل میشود
</p>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
نام کامل <span className="text-red-500">*</span>
</label>
<input type="text"
className={`cp-input ${errors.name ? 'border-red-400 focus:ring-red-400' : ''}`}
placeholder="دکتر محمد احمدی"
{...register('name')} />
{errors.name && <p className="text-xs text-red-500 mt-1">{errors.name.message}</p>}
</div>
</div>
</div>
{/* Professional info */}
<div className="cp-card p-6 space-y-4">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 border-b border-slate-100 dark:border-gray-700 pb-3">
اطلاعات حرفهای
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Gender */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">جنسیت</label>
<select className="cp-select h-11" {...register('gender')}>
<option value="">انتخاب کنید</option>
{GENDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
{/* Degree */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">درجه تحصیلی</label>
<select className="cp-select h-11" {...register('degree')}>
<option value="">انتخاب کنید</option>
{DEGREE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
{/* Medical system code */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">کد نظام پزشکی</label>
<input type="text" dir="ltr"
className="cp-input text-left"
placeholder="123456"
{...register('medical_system_code')} />
{/* ── Section: اطلاعات حساب ──────────────────────────────── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<SectionHeader icon={<PhoneIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حساب" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<Field label="شماره موبایل" required error={errors.mobile?.message} hint="اگر کاربر با این شماره وجود دارد پروفایل به او متصل می‌شود">
<input type="tel" dir="ltr" className="cp-input" placeholder="09xxxxxxxxx" {...register('mobile')}
style={errors.mobile ? { borderColor: 'var(--danger)' } : {}} />
</Field>
<Field label="نام کامل" required error={errors.name?.message}>
<input type="text" className="cp-input" placeholder="دکتر ..." {...register('name')}
style={errors.name ? { borderColor: 'var(--danger)' } : {}} />
</Field>
</div>
</div>
{/* Bio */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">بیوگرافی</label>
<textarea rows={4}
className="cp-input resize-none"
placeholder="معرفی کوتاهی از پزشک..."
{...register('info')} />
</div>
</div>
{/* ── Section: اطلاعات حرفه‌ای ────────────────────────────── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<SectionHeader icon={<IdentificationIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حرفه‌ای" />
{/* Specialties */}
<div className="cp-card p-6 space-y-3">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 border-b border-slate-100 dark:border-gray-700 pb-3">
تخصصها
{selectedSpecialties.length > 0 && (
<span className="text-xs font-normal text-primary-600 dark:text-primary-400 mr-2">
{formatNumber(selectedSpecialties.length)} انتخاب شده
</span>
)}
</h2>
{specialtiesQ.isLoading ? (
<div className="h-32 rounded-xl skeleton" />
) : (
<SpecialtyPicker
selected={selectedSpecialties}
onChange={setSelectedSpecialties}
specialties={specialties}
{/* Gender toggle */}
<div style={{ marginBottom: 14 }}>
<label className="cp-label">جنسیت</label>
<div style={{ display: 'flex', gap: 8 }}>
{GENDER_OPTIONS.map(o => (
<button key={o.value} type="button" onClick={() => setGender(gender === o.value ? '' : o.value as 'man' | 'woman')}
style={{
flex: 1, height: 42, borderRadius: 'var(--r-sm)', border: `1.5px solid ${gender === o.value ? 'var(--primary)' : 'var(--border)'}`,
background: gender === o.value ? 'var(--primary-soft)' : 'var(--surface)',
color: gender === o.value ? 'var(--primary-700)' : 'var(--text-2)',
fontWeight: gender === o.value ? 700 : 400, fontSize: 14, cursor: 'pointer', transition: 'all .14s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}>
<span style={{ fontSize: 16 }}>{o.icon}</span>{o.label}
</button>
))}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<Field label="درجه تحصیلی">
<Controller name="degree" control={control} render={({ field }) => (
<SearchableSelect
options={DEGREE_OPTIONS}
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? '')}
placeholder="انتخاب کنید"
isClearable
/>
)} />
</Field>
<Field label="کد نظام پزشکی">
<input type="text" dir="ltr" className="cp-input" placeholder="123456" {...register('medical_system_code')} />
</Field>
</div>
<div style={{ marginTop: 14 }}>
<Field label="بیوگرافی">
<textarea rows={3} className="cp-textarea" placeholder="معرفی کوتاهی از پزشک..." {...register('info')} style={{ resize: 'none' }} />
</Field>
</div>
</div>
{/* ── Section: تخصص‌ها ────────────────────────────────────── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<SectionHeader
icon={<AcademicCapIcon style={{ width: 16, height: 16 }} />}
title="تخصص‌ها"
badge={selectedSpecialties.length > 0 ? `${selectedSpecialties.length} انتخاب شده` : undefined}
/>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pb-4">
<button type="button" onClick={() => navigate('/admin/doctors')} className="cp-btn-secondary px-6">
لغو
</button>
<button type="submit" disabled={createMut.isPending} className="cp-btn-primary px-8">
{createMut.isPending ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
در حال ذخیره...
</span>
{specialtiesQ.isLoading ? (
<div style={{ height: 200, borderRadius: 'var(--r)', overflow: 'hidden' }} className="skeleton" />
) : (
<span className="flex items-center gap-2">
<UserPlusIcon className="w-4 h-4" />افزودن پزشک
</span>
<SpecialtyPicker
selected={selectedSpecialties}
onChange={setSelectedSpecialties}
specialties={specialties}
/>
)}
</button>
</div>
{/* ── Actions ─────────────────────────────────────────────── */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 10, paddingBottom: 8 }}>
<button type="button" onClick={() => navigate('/admin/doctors')} className="cp-btn-secondary">
انصراف
</button>
<button type="submit" disabled={createMut.isPending} className="cp-btn-primary" style={{ minWidth: 140 }}>
{createMut.isPending ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 16, height: 16, border: '2px solid rgba(255,255,255,.3)', borderTopColor: '#fff', borderRadius: '50%', animation: 'spin .7s linear infinite', flexShrink: 0 }} />
در حال ذخیره...
</span>
) : (
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<UserPlusIcon style={{ width: 16, height: 16 }} />
افزودن پزشک
</span>
)}
</button>
</div>
</div>
</form>
</div>
);
}
// ── Section Header ────────────────────────────────────────────────────────────
function SectionHeader({ icon, title, badge }: { icon: React.ReactNode; title: string; badge?: string }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 18, paddingBottom: 14, borderBottom: '1px solid var(--border)' }}>
<div style={{ width: 30, height: 30, borderRadius: 8, background: 'var(--primary-soft)', color: 'var(--primary-700)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
{icon}
</div>
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>{title}</span>
{badge && (
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--primary-700)', background: 'var(--primary-soft)', padding: '2px 10px', borderRadius: 999 }}>
{badge}
</span>
)}
</div>
);
}
+10 -7
View File
@@ -12,6 +12,7 @@ import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatDate, formatNumber } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
// ── Types ─────────────────────────────────────────────────────────────────
@@ -227,13 +228,15 @@ export default function DoctorsPage() {
/>
</div>
{specialties.length > 0 && (
<div className="field">
<select value={specialtyId} onChange={(e) => setSpecialty(e.target.value)}>
<option value="">همه تخصصها</option>
{specialties.map((s) => (
<option key={s.id} value={String(s.id)}>{s.name}</option>
))}
</select>
<div style={{ minWidth: 180 }}>
<SearchableSelect
options={specialties.map(s => ({ value: String(s.id), label: s.name }))}
value={specialtyId || null}
onChange={(v) => setSpecialty(v ? String(v) : '')}
placeholder="همه تخصص‌ها"
isClearable
height={36}
/>
</div>
)}
<div className="seg">
@@ -11,6 +11,7 @@ import PageHeader from '../components/ui/PageHeader';
import { ActiveBadge } from '../components/ui/StatusBadge';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Modal from '../components/ui/Modal';
import SearchableSelect from '../components/ui/SearchableSelect';
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
@@ -244,16 +245,13 @@ export default function RepresentationDetailPage() {
</div>
<div>
<label className="">شهر</label>
<select
value={formData.city_id}
onChange={(e) => setFormData((p) => ({ ...p, city_id: e.target.value }))}
className="cp-input h-11"
>
<option value="">انتخاب شهر</option>
{cities.map((c) => (
<option key={c.id} value={String(c.id)}>{c.name}</option>
))}
</select>
<SearchableSelect
options={cities.map(c => ({ value: String(c.id), label: c.name }))}
value={formData.city_id || null}
onChange={(v) => setFormData(p => ({ ...p, city_id: v ? String(v) : '' }))}
placeholder="انتخاب شهر"
isClearable
/>
</div>
<div>
<label className="">موبایل</label>
+8 -6
View File
@@ -12,6 +12,7 @@ import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
import { XMarkIcon } from '@heroicons/react/24/outline';
// ── Types ─────────────────────────────────────────────────────────────────
@@ -292,12 +293,13 @@ export default function UsersPage() {
placeholder="نام، موبایل یا ایمیل..."
/>
</div>
<div className="field">
<select value={role} onChange={(e) => setRole(e.target.value)}>
{ROLE_TABS.map((t) => (
<option key={t.key} value={t.key}>{t.label}</option>
))}
</select>
<div style={{ minWidth: 160 }}>
<SearchableSelect
options={ROLE_TABS.map(t => ({ value: t.key, label: t.label }))}
value={role || ''}
onChange={(v) => setRole(v ? String(v) : '')}
height={36}
/>
</div>
<div className="seg">
<button className={!status ? 'on' : ''} onClick={() => setStatus('')}>همه</button>
@@ -484,6 +484,38 @@ class AdminApiController extends BaseController
return $this->paginated($items, $total, $page, $limit);
}
#[Route('/api/v1/admin/clinic', methods: ['POST'])]
public function createClinic(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true) ?? [];
$mobile = trim((string) ($data['owner_mobile'] ?? ''));
$name = trim((string) ($data['name'] ?? ''));
if ($mobile === '') {
return $this->error('VALIDATION', 'شماره موبایل الزامی است', 422);
}
if ($name === '') {
return $this->error('VALIDATION', 'نام کلینیک الزامی است', 422);
}
$user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]);
if (!$user) {
$user = new User($mobile);
$this->em->persist($user);
}
$clinic = new Clinic($user);
$clinic->setName($name);
if (!empty($data['telephone'])) $clinic->setTelephone($data['telephone']);
if (!empty($data['address'])) $clinic->setAddress($data['address']);
if (!empty($data['info'])) $clinic->setInfo($data['info']);
$this->em->persist($clinic);
$this->em->flush();
return $this->success(['uuid' => $clinic->getUuid(), 'name' => $clinic->getName(), 'is_active' => $clinic->isActive()]);
}
#[Route('/api/v1/admin/clinic/{uuid}/status', methods: ['PATCH'])]
public function toggleClinicStatus(string $uuid): JsonResponse
{