feat: unify doctor title handling and enhance specialty selection

- Implemented a helper function `displayDoctorName` to prepend "دکتر" to doctor names for consistent display across the application.
- Updated various components (InviteDoctorModal, DashboardPage, DoctorDetailPage, DoctorsPage, etc.) to utilize the new helper for rendering doctor names.
- Modified the DoctorFormPage to automatically add the "دکتر" title in the UI without requiring user input.
- Fixed the EditSpecialtyPicker component to allow multiple specialty selections, resolving a UI bug where only one specialty could be selected at a time.
- Ensured that the backend strips the "دکتر" title from the name during pre-registration and doctor creation processes.
- Added tests for the new functionality, including checks for title handling and specialty selection logic.
- Updated API documentation to reflect changes in name handling and display logic.
This commit is contained in:
hamed
2026-07-19 19:57:03 +03:30
parent 801c6f96db
commit 74577c2ff6
17 changed files with 482 additions and 40 deletions
@@ -56,7 +56,7 @@ export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Pr
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>نام پزشک (اختیاری)</label>
<input className="input" placeholder="دکتر نام و نام خانوادگی" {...register('name')} />
<input className="input" placeholder="نام و نام خانوادگی" {...register('name')} />
</div>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>تخصص (اختیاری)</label>
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
import { displayDoctorName } from './utils';
describe('displayDoctorName', () => {
it('prepends the title to a bare name', () => {
expect(displayDoctorName('حامد حسینی')).toBe('دکتر حامد حسینی');
});
it('does not double the title when the name already starts with it', () => {
expect(displayDoctorName('دکتر حامد حسینی')).toBe('دکتر حامد حسینی');
});
it('trims surrounding whitespace before deciding', () => {
expect(displayDoctorName(' حامد حسینی ')).toBe('دکتر حامد حسینی');
});
it('returns empty string for empty/nullish input (no lone «دکتر»)', () => {
expect(displayDoctorName('')).toBe('');
expect(displayDoctorName(null)).toBe('');
expect(displayDoctorName(undefined)).toBe('');
expect(displayDoctorName(' ')).toBe('');
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { toggleSpecialtyChild, toggleSpecialtyRoot, removeSpecialtyEntry, type ChildMap } from './specialtySelection';
// درخت نمونه: والد ۱۰ با فرزندهای ۱۱ و ۱۲؛ والد ۲۰ با فرزند ۲۱؛ ریشهٔ بی‌فرزند ۳۰.
const childMap: ChildMap = {
10: [{ id: 11 }, { id: 12 }],
20: [{ id: 21 }],
};
describe('toggleSpecialtyChild', () => {
it('adds a child together with its parent', () => {
expect(toggleSpecialtyChild([], 11, 10, childMap)).toEqual([10, 11]);
});
it('allows a second specialty from another group — the bug that was fixed', () => {
expect(toggleSpecialtyChild([10, 11], 21, 20, childMap)).toEqual([10, 11, 20, 21]);
});
it('adds a sibling and keeps the shared parent', () => {
expect(toggleSpecialtyChild([10, 11], 12, 10, childMap)).toEqual([10, 11, 12]);
});
it('removing one sibling keeps the parent while another sibling stays', () => {
expect(toggleSpecialtyChild([10, 11, 12], 11, 10, childMap)).toEqual([10, 12]);
});
it('removing the last child of a group also drops the parent', () => {
expect(toggleSpecialtyChild([10, 11], 11, 10, childMap)).toEqual([]);
});
it('never duplicates the parent id', () => {
const out = toggleSpecialtyChild([10, 11], 12, 10, childMap);
expect(out.filter(id => id === 10)).toHaveLength(1);
});
});
describe('toggleSpecialtyRoot', () => {
it('adds a childless root', () => {
expect(toggleSpecialtyRoot([], 30)).toEqual([30]);
});
it('adds a root next to an existing selection', () => {
expect(toggleSpecialtyRoot([10, 11], 30)).toEqual([10, 11, 30]);
});
it('toggles a root off without touching the rest', () => {
expect(toggleSpecialtyRoot([10, 11, 30], 30)).toEqual([10, 11]);
});
});
describe('removeSpecialtyEntry', () => {
it('removes only the targeted child+parent, not the whole selection', () => {
expect(removeSpecialtyEntry([10, 11, 20, 21], 11, 10, childMap)).toEqual([20, 21]);
});
it('keeps the parent when a sibling remains', () => {
expect(removeSpecialtyEntry([10, 11, 12], 11, 10, childMap)).toEqual([10, 12]);
});
it('removes a childless root entry', () => {
expect(removeSpecialtyEntry([10, 11, 30], 30, null, childMap)).toEqual([10, 11]);
});
});
+46
View File
@@ -0,0 +1,46 @@
// منطق خالص انتخاب چند‌تخصصی برای پیکر درختی پروفایل پزشک.
// تخصص‌ها درختی‌اند: انتخاب یک فرزند، والدش را هم نگه می‌دارد (والد صرفاً برای
// گسترش درختی سمت سرور است) و والد فقط وقتی حذف می‌شود که هیچ فرزند دیگری از او
// انتخاب نمانده باشد. جدا از کامپوننت نگه داشته شده تا مستقل تست شود.
/** id فرزندهای هر والد. */
export type ChildMap = Record<number, { id: number }[]>;
const uniq = (ids: number[]): number[] => [...new Set(ids)];
/** toggle یک تخصصِ فرزند؛ والد را در صورت لزوم اضافه/حذف می‌کند. */
export function toggleSpecialtyChild(
selected: number[],
childId: number,
parentId: number,
childMap: ChildMap,
): number[] {
if (selected.includes(childId)) {
const siblings = childMap[parentId] ?? [];
const otherSelected = siblings.some(k => k.id !== childId && selected.includes(k.id));
return selected.filter(id => id !== childId && (otherSelected || id !== parentId));
}
return uniq([...selected, parentId, childId]);
}
/** toggle یک تخصصِ ریشه‌ایِ بدون فرزند. */
export function toggleSpecialtyRoot(selected: number[], rootId: number): number[] {
return selected.includes(rootId)
? selected.filter(id => id !== rootId)
: uniq([...selected, rootId]);
}
/** حذف یک chip — فقط همان تخصص (و والدِ بی‌فرزندش)، نه پاک‌کردن همه. */
export function removeSpecialtyEntry(
selected: number[],
childId: number,
parentId: number | null,
childMap: ChildMap,
): number[] {
if (parentId !== null) {
const siblings = childMap[parentId] ?? [];
const otherSelected = siblings.some(k => k.id !== childId && selected.includes(k.id));
return selected.filter(id => id !== childId && (otherSelected || id !== parentId));
}
return selected.filter(id => id !== childId);
}
+9
View File
@@ -98,6 +98,15 @@ export function unixToIso(ts: number | null | undefined): string {
return toGregorianDate(new Date(ts * 1000));
}
// عنوان «دکتر» فقط در نمایش افزوده می‌شود و هرگز در فیلد name دیتابیس ذخیره نمی‌شود
// (backend با stripDoctorTitle حذف می‌کند). این helper تنها نقطهٔ افزودن عنوان است تا
// در کل پنل یکسان باشد و از «دکتر دکتر …» یا نمایش بدون عنوان جلوگیری شود.
export function displayDoctorName(name?: string | null): string {
const n = (name ?? '').trim();
if (!n) return '';
return n.startsWith('دکتر') ? n : `دکتر ${n}`;
}
export function maskMobile(mobile: string): string {
if (mobile.length < 7) return mobile;
return mobile.slice(0, 4) + '***' + mobile.slice(-3);
+3 -3
View File
@@ -9,7 +9,7 @@ import {
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { formatNumber, formatRial, formatDateTime } from '../lib/utils';
import { formatNumber, formatRial, formatDateTime, displayDoctorName } from '../lib/utils';
import { useAuthStore } from '../stores/authStore';
import { TauriDashboardView } from '../components/dashboard/TauriDashboardView';
@@ -901,7 +901,7 @@ function SecretaryDashboard() {
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
<div>
<h1 className="section-title">داشبورد منشی</h1>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · منشی دکتر {d?.doctor.name ?? ''}</div>
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>{today} · منشی {displayDoctorName(d?.doctor.name)}</div>
</div>
<button className="btn ghost sm" onClick={() => q.refetch()}>
<ArrowPathIcon style={{ width: 14, height: 14 }} />
@@ -912,7 +912,7 @@ function SecretaryDashboard() {
<div className="card card-pad" style={{ marginBottom: 'var(--gap)', display: 'flex', alignItems: 'center', gap: 16 }}>
<AvatarEl initials={(d?.doctor.name ?? 'D').slice(0, 1)} hue={256} size="lg" />
<div>
<div style={{ fontWeight: 700, fontSize: 16 }}>دکتر {d?.doctor.name ?? '—'}</div>
<div style={{ fontWeight: 700, fontSize: 16 }}>{displayDoctorName(d?.doctor.name) || '—'}</div>
{d?.doctor.degree && <div className="muted" style={{ fontSize: 13, marginTop: 3 }}>{d.doctor.degree}</div>}
</div>
<div style={{ marginRight: 'auto', display: 'flex', gap: 8 }}>
+13 -28
View File
@@ -23,7 +23,7 @@ import 'leaflet/dist/leaflet.css';
import { api, ApiError } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import type { ApiResponse } from '../lib/api';
import { formatNumber, iranMobileOptionalSchema, toDate, toGregorianDate } from '../lib/utils';
import { formatNumber, iranMobileOptionalSchema, toDate, toGregorianDate, displayDoctorName } from '../lib/utils';
import MobileInput from '../components/ui/MobileInput';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
@@ -34,6 +34,7 @@ import ImageCropModal from '../components/ImageCropModal';
import { ScheduleSection } from '../components/schedule/ScheduleSection';
import type { AddressData } from '../components/schedule/ScheduleSection';
import { latinDigitsField } from '../lib/forms';
import { toggleSpecialtyChild, toggleSpecialtyRoot, removeSpecialtyEntry } from '../lib/specialtySelection';
// Fix leaflet default marker icons
delete (L.Icon.Default.prototype as any)._getIconUrl;
@@ -856,25 +857,13 @@ function EditSpecialtyPicker({ selected, onChange, specialties }: {
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 selectChild = (child: SpecialtyOpt) =>
onChange(toggleSpecialtyChild(selected, child.id, child.parent_id!, childMap));
const selectRoot = (root: SpecialtyOpt) => {
if (selected.includes(root.id)) {
onChange([]);
} else {
setActiveParentId(null);
onChange([root.id]);
}
};
const selectRoot = (root: SpecialtyOpt) => onChange(toggleSpecialtyRoot(selected, root.id));
const removeEntry = () => { onChange([]); setActiveParentId(null); };
const removeEntry = ({ parentId, childId }: EditSelectedEntry) =>
onChange(removeSpecialtyEntry(selected, childId, parentId, childMap));
const chips: EditSelectedEntry[] = useMemo(() => {
return selected
@@ -901,7 +890,7 @@ function EditSpecialtyPicker({ selected, onChange, specialties }: {
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()}
<button type="button" onClick={() => removeEntry({ parentId, childId })}
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>
);
@@ -919,19 +908,15 @@ function EditSpecialtyPicker({ selected, onChange, specialties }: {
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,
cursor: 'pointer',
background: isActive ? 'var(--primary-soft)' : rootSel ? 'var(--primary-soft)' : 'transparent',
color: (isActive || rootSel) ? 'var(--primary-700)' : 'var(--text)',
fontWeight: (isActive || rootSel) ? 700 : 400,
@@ -964,7 +949,7 @@ function EditSpecialtyPicker({ selected, onChange, specialties }: {
) : (
<>
<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);
@@ -1442,7 +1427,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
</div>
<div className="flex-1 min-w-0 sm:mb-1">
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">دکتر {doctor.name}</h1>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">{displayDoctorName(doctor.name)}</h1>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{/* Active status badge */}
{doctor.active
@@ -1720,7 +1705,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
<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')} />
<input type="text" className="cp-input" placeholder="مثلاً: حامد حسینی" {...register('name')} />
</EditField>
<EditField label="شماره موبایل مطب">
<MobileInput className="cp-input" {...register('mobile_number')} />
@@ -1823,7 +1808,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil
) : (
<EditSpecialtyPicker
selected={watchedSpecialties}
onChange={ids => setValue('specialties', ids)}
onChange={ids => setValue('specialties', ids, { shouldDirty: true })}
specialties={specialties}
/>
)}
+13 -3
View File
@@ -327,9 +327,19 @@ export default function DoctorFormPage() {
<Field label="شماره موبایل" required error={errors.mobile?.message} hint="اگر کاربر با این شماره وجود دارد پروفایل به او متصل می‌شود">
<MobileInput hasError={!!errors.mobile} {...register('mobile')} />
</Field>
<Field label="نام کامل" required error={errors.name?.message}>
<input type="text" className="cp-input" placeholder="دکتر ..." {...register('name')}
style={errors.name ? { borderColor: 'var(--danger)' } : {}} />
<Field label="نام کامل" required error={errors.name?.message} hint="عنوان «دکتر» خودکار اضافه می‌شود؛ لازم نیست تایپ کنید">
<div className="cp-input" style={{
display: 'flex', alignItems: 'center', gap: 6, padding: 0, overflow: 'hidden',
...(errors.name ? { borderColor: 'var(--danger)' } : {}),
}}>
<span style={{
flexShrink: 0, alignSelf: 'stretch', display: 'flex', alignItems: 'center',
padding: '0 12px', background: 'var(--surface-2)', color: 'var(--text-2)',
fontWeight: 600, fontSize: 14, borderInlineEnd: '1px solid var(--border)',
}}>دکتر</span>
<input type="text" placeholder="مثلاً: حامد حسینی" {...register('name')}
style={{ flex: 1, minWidth: 0, border: 'none', outline: 'none', background: 'transparent', padding: '0 12px', height: '100%', color: 'inherit', font: 'inherit' }} />
</div>
</Field>
</div>
</div>
+3 -3
View File
@@ -9,7 +9,7 @@ import { XMarkIcon } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatDate, formatNumber } from '../lib/utils';
import { formatDate, formatNumber, displayDoctorName } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Pagination from '../components/ui/Pagination';
import SearchableSelect from '../components/ui/SearchableSelect';
@@ -376,7 +376,7 @@ export default function DoctorsPage() {
<div className="cell-user">
<DoctorAvatar name={doc.name} id={doc.id} image={doc.profile_image} />
<div>
<b>دکتر {doc.name}</b>
<b>{displayDoctorName(doc.name)}</b>
<br /><small>{doc.gender ?? '—'}</small>
</div>
</div>
@@ -478,7 +478,7 @@ export default function DoctorsPage() {
<DoctorAvatar name={doc.name} id={doc.id} image={doc.profile_image} />
<div style={{ flex: 1, minWidth: 0 }}>
<b style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
دکتر {doc.name}
{displayDoctorName(doc.name)}
</b>
<span className="muted" style={{ fontSize: 12.5 }}>
{doc.specialties[0]?.name ?? '—'}