feat: enhance DoctorDetailPage with edit form helpers and gender selection options

This commit is contained in:
hamed
2026-06-12 22:58:28 +03:30
parent 87966a90fa
commit 0b5d017b14
+317 -79
View File
@@ -12,6 +12,7 @@ import {
BuildingOfficeIcon, MapPinIcon, AcademicCapIcon, UserIcon,
PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon,
CheckCircleIcon, XMarkIcon, ExclamationTriangleIcon,
HeartIcon, CheckIcon, IdentificationIcon, DocumentTextIcon,
} from '@heroicons/react/24/outline';
import { StarIcon as StarSolid } from '@heroicons/react/24/solid';
import { toast } from 'sonner';
@@ -1784,6 +1785,221 @@ function ScheduleSection({ doctorUuid }: { doctorUuid: string }) {
);
}
// ── Edit form helpers ──────────────────────────────────────────────────────
const EDIT_DEGREE_OPTIONS = [
{ value: 'general', label: 'پزشک عمومی' },
{ value: 'specialist', label: 'متخصص' },
{ value: 'expert', label: 'فوق تخصص' },
{ value: 'subspecialistplus', label: 'فلوشیپ' },
];
const EDIT_GENDER_OPTIONS = [
{ value: 'man', label: 'مرد', icon: '♂' },
{ value: 'woman', label: 'زن', icon: '♀' },
];
function EditField({ 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>
);
}
function EditSectionHeader({ 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>
);
}
interface EditSelectedEntry { parentId: number | null; childId: number }
function EditSpecialtyPicker({ selected, onChange, specialties }: {
selected: number[]; onChange: (ids: number[]) => void;
specialties: SpecialtyOpt[];
}) {
const [activeParentId, setActiveParentId] = useState<number | null>(null);
const parents = useMemo(() => specialties.filter(s => s.parent_id === null), [specialties]);
const childMap = useMemo(() => {
const m: Record<number, SpecialtyOpt[]> = {};
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 activeChildren = activeParentId !== null ? (childMap[activeParentId] ?? []) : [];
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: SpecialtyOpt) => {
const parentId = child.parent_id!;
if (selected.includes(child.id)) {
onChange([]);
} else {
onChange([parentId, child.id]);
}
};
const selectRoot = (root: SpecialtyOpt) => {
if (selected.includes(root.id)) {
onChange([]);
} else {
setActiveParentId(null);
onChange([root.id]);
}
};
const removeEntry = () => { onChange([]); setActiveParentId(null); };
const chips: EditSelectedEntry[] = 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 };
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;
})
.filter(Boolean) as EditSelectedEntry[];
}, [selected, specialties, childMap]);
return (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r)', overflow: 'hidden' }}>
{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={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 style={{ display: 'flex', minHeight: 240 }}>
<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 => {
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>
<div style={{ flex: 1, overflowY: 'auto', maxHeight: 300 }}>
{activeParentId === null ? (
<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>
) : activeChildren.length === 0 ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', color: 'var(--text-3)', fontSize: 13 }}>
زیرمجموعهای یافت نشد
</div>
) : (
<>
<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>
{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>
</div>
</div>
);
}
// ── Edit schema ────────────────────────────────────────────────────────────
const editSchema = z.object({
@@ -1817,6 +2033,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
const [addrModalOpen, setAddrModalOpen] = useState(false);
const [editingAddr, setEditingAddr] = useState<AddressData | null>(null);
const [deletingAddrId, setDeletingAddrId] = useState<string | null>(null);
const [editGender, setEditGender] = useState<'man' | 'woman' | ''>('');
// ── Queries ──
@@ -1866,6 +2083,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
specialties: doctor.specialties.map(s => Number(s.id)),
services: doctor.expertise.map(s => Number(s.id)),
});
setEditGender((doctor.gender as any) ?? '');
}
}, [doctor, reset]);
@@ -1884,7 +2102,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
const updateMut = useMutation({
mutationFn: (body: EditForm) => api.patch<ApiResponse<any>>(`/api/v1/doctor/${uuid}`, {
title: body.name,
gender: body.gender || undefined,
gender: editGender || undefined,
degree: body.degree || undefined,
medical_system_code: body.medical_system_code || undefined,
mobile_number: body.mobile_number || undefined,
@@ -2260,92 +2478,112 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<Modal open={editOpen} title="ویرایش اطلاعات پزشک" size="lg" onClose={() => setEditOpen(false)}
footer={
<>
<button onClick={() => setEditOpen(false)} className="cp-btn-secondary px-5">لغو</button>
<button form="edit-doctor-form" type="submit" disabled={updateMut.isPending} className="btn primary sm">
<button onClick={() => setEditOpen(false)} className="cp-btn-secondary">لغو</button>
<button form="edit-doctor-form" type="submit" disabled={updateMut.isPending} className="cp-btn-primary" style={{ minWidth: 130 }}>
{updateMut.isPending ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
</button>
</>
}
>
<form id="edit-doctor-form" onSubmit={handleSubmit(v => updateMut.mutate(v))} className="space-y-5">
<div className="space-y-4">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">اطلاعات پایه</p>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">نام کامل *</label>
<input type="text" className="input" {...register('name')} />
</div>
<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>
<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>
<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>
<input type="text" dir="ltr" className="cp-input text-left" {...register('medical_system_code')} />
</div>
<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="09..." {...register('mobile_number')} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">بیوگرافی</label>
<textarea rows={3} className="cp-input resize-none" {...register('info')} />
</div>
</div>
<form id="edit-doctor-form" onSubmit={handleSubmit(v => updateMut.mutate(v))}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="space-y-2 border-t border-slate-100 dark:border-gray-700 pt-4">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
تخصصها
{watchedSpecialties.length > 0 && (
<span className="text-primary-600 dark:text-primary-400 mr-2 normal-case font-normal">
({formatNumber(watchedSpecialties.length)} انتخاب)
</span>
)}
</p>
<HierarchicalSpecialtyPicker
selected={watchedSpecialties}
onChange={ids => setValue('specialties', ids)}
specialties={specialties}
/>
</div>
{/* ── Section: اطلاعات حساب ── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<EditSectionHeader icon={<PhoneIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حساب" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<EditField label="نام کامل" required error={(register('name') as any)?.formState?.errors?.name?.message}>
<input type="text" className="cp-input" placeholder="دکتر ..." {...register('name')} />
</EditField>
<EditField label="شماره موبایل مطب">
<input type="text" dir="ltr" className="cp-input" placeholder="09xxxxxxxxx" {...register('mobile_number')} />
</EditField>
</div>
</div>
<div className="space-y-2 border-t border-slate-100 dark:border-gray-700 pt-4">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
خدمات
{watchedServices.length > 0 && (
<span className="text-emerald-600 dark:text-emerald-400 mr-2 normal-case font-normal">
({formatNumber(watchedServices.length)} انتخاب)
</span>
{/* ── Section: اطلاعات حرفه‌ای ── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<EditSectionHeader icon={<IdentificationIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حرفه‌ای" />
{/* Gender toggle */}
<div style={{ marginBottom: 14 }}>
<label className="cp-label">جنسیت</label>
<div style={{ display: 'flex', gap: 8 }}>
{EDIT_GENDER_OPTIONS.map(o => (
<button key={o.value} type="button"
onClick={() => setEditGender(editGender === o.value ? '' : o.value as 'man' | 'woman')}
style={{
flex: 1, height: 42, borderRadius: 'var(--r-sm)',
border: `1.5px solid ${editGender === o.value ? 'var(--primary)' : 'var(--border)'}`,
background: editGender === o.value ? 'var(--primary-soft)' : 'var(--surface)',
color: editGender === o.value ? 'var(--primary-700)' : 'var(--text-2)',
fontWeight: editGender === 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 }}>
<EditField label="درجه تحصیلی">
<Controller name="degree" control={editControl} render={({ field }) => (
<GlobalSearchableSelect
options={EDIT_DEGREE_OPTIONS}
value={field.value ?? null}
onChange={(v) => field.onChange(v ?? '')}
placeholder="انتخاب کنید"
isClearable
/>
)} />
</EditField>
<EditField label="کد نظام پزشکی">
<input type="text" dir="ltr" className="cp-input" placeholder="123456" {...register('medical_system_code')} />
</EditField>
</div>
<div style={{ marginTop: 14 }}>
<EditField label="بیوگرافی">
<textarea rows={3} className="cp-textarea" placeholder="معرفی کوتاهی از پزشک..." {...register('info')} style={{ resize: 'none' }} />
</EditField>
</div>
</div>
{/* ── Section: تخصص‌ها ── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<EditSectionHeader
icon={<AcademicCapIcon style={{ width: 16, height: 16 }} />}
title="تخصص‌ها"
badge={watchedSpecialties.length > 0 ? `${watchedSpecialties.length} انتخاب شده` : undefined}
/>
{specialtiesQ.isLoading ? (
<div style={{ height: 200, borderRadius: 'var(--r)', overflow: 'hidden' }} className="skeleton" />
) : (
<EditSpecialtyPicker
selected={watchedSpecialties}
onChange={ids => setValue('specialties', ids)}
specialties={specialties}
/>
)}
</p>
<ServicesPicker
selected={watchedServices}
onChange={ids => setValue('services', ids)}
services={services}
specialties={specialties}
selectedSpecialtyIds={watchedSpecialties}
/>
</div>
{/* ── Section: خدمات ── */}
<div className="cp-card" style={{ padding: 'var(--card-pad)' }}>
<EditSectionHeader
icon={<DocumentTextIcon style={{ width: 16, height: 16 }} />}
title="خدمات"
badge={watchedServices.length > 0 ? `${watchedServices.length} انتخاب شده` : undefined}
/>
<ServicesPicker
selected={watchedServices}
onChange={ids => setValue('services', ids)}
services={services}
specialties={specialties}
selectedSpecialtyIds={watchedSpecialties}
/>
</div>
</div>
</form>
</Modal>