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
@@ -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);