feat(appointments): enhance doctor and secretary views with scheduling context and resource management
This commit is contained in:
@@ -233,15 +233,21 @@ describe('AppointmentsPage — منشی', () => {
|
|||||||
* منشی در هر دو ساختار باید تایملاین ببیند. لیست پزشکانِ مجاز از اندپوینت
|
* منشی در هر دو ساختار باید تایملاین ببیند. لیست پزشکانِ مجاز از اندپوینت
|
||||||
* احرازشدهٔ /my/clinic-doctors میآید (نه لیست عمومی کلینیک).
|
* احرازشدهٔ /my/clinic-doctors میآید (نه لیست عمومی کلینیک).
|
||||||
*/
|
*/
|
||||||
function mockSecretary(doctors: { uuid: string; name: string }[], scope: string | null) {
|
function mockSecretary(
|
||||||
|
doctors: { uuid: string; name: string; has_schedule?: boolean }[],
|
||||||
|
scope: string | null,
|
||||||
|
resources: unknown[] = [],
|
||||||
|
) {
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
primaryRole: 'secretary',
|
primaryRole: 'secretary',
|
||||||
dbUuid: scope === 'clinic' ? 'clinic1' : 'doc1',
|
dbUuid: scope === 'clinic' ? 'clinic1' : 'doc1',
|
||||||
context: scope ? { type: scope, scope } : null,
|
context: scope ? { type: scope, scope } : null,
|
||||||
|
availableContexts: [],
|
||||||
} as any);
|
} as any);
|
||||||
get.mockImplementation((url?: string) => {
|
get.mockImplementation((url?: string) => {
|
||||||
if (typeof url !== 'string') return Promise.resolve({ success: true, data: [] });
|
if (typeof url !== 'string') return Promise.resolve({ success: true, data: [] });
|
||||||
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 11, completed: 2, waiting: 7, cancelled: 2 } });
|
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 11, completed: 2, waiting: 7, cancelled: 2 } });
|
||||||
|
if (url.includes('/api/v1/resources')) return Promise.resolve({ success: true, data: resources });
|
||||||
if (url.includes('/my/clinic-doctors')) return Promise.resolve({ success: true, data: { data: doctors } });
|
if (url.includes('/my/clinic-doctors')) return Promise.resolve({ success: true, data: { data: doctors } });
|
||||||
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
|
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
|
||||||
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||||
@@ -280,4 +286,83 @@ describe('AppointmentsPage — منشی', () => {
|
|||||||
const usedPublicList = get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('/clinic/doctor-list/'));
|
const usedPublicList = get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('/clinic/doctor-list/'));
|
||||||
expect(usedPublicList).toBe(false);
|
expect(usedPublicList).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** منشی هم باید همان پیام و همان میانبُرِ منابع را ببیند، نه تایملاین خالی. */
|
||||||
|
it('منشی: پزشکِ بدون برنامه پیام میگیرد و منابعش قابل رزروند', async () => {
|
||||||
|
mockSecretary([{ uuid: 'doc1', name: 'دکتر موسوی', has_schedule: false }], null, [
|
||||||
|
{ uuid: 'r-9', name: 'لیزر', type_name: 'دستگاه', supervisor: { uuid: 'doc1', name: 'دکتر موسوی' } },
|
||||||
|
]);
|
||||||
|
renderWithProviders(<AppointmentsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('ساعت کاری تنظیم نشده است')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('این روز تعطیل است')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* پروفایل خودِ پزشک: تب پزشکان ندارد، پس تنها راهِ دانستنِ «برنامه ندارم» همان
|
||||||
|
* `has_schedule`ِ /my/clinic-doctors است.
|
||||||
|
*/
|
||||||
|
describe('AppointmentsPage — پروفایل خود پزشک بدون برنامهٔ کاری', () => {
|
||||||
|
function mockDoctor(hasSchedule: boolean, contexts: any[] = []) {
|
||||||
|
useAuthStore.setState({
|
||||||
|
primaryRole: 'doctor', dbUuid: 'doc1', doctorUuid: 'doc1',
|
||||||
|
context: { type: 'doctor', db_uuid: 'doc1', name: 'مطب من', role: 'doctor' },
|
||||||
|
availableContexts: contexts,
|
||||||
|
} as any);
|
||||||
|
get.mockImplementation((url?: string) => {
|
||||||
|
if (typeof url !== 'string') return Promise.resolve({ success: true, data: [] });
|
||||||
|
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 0, completed: 0, waiting: 0, cancelled: 0 } });
|
||||||
|
if (url.includes('/my/clinic-doctors')) {
|
||||||
|
return Promise.resolve({ success: true, data: { data: [{ uuid: 'doc1', name: 'دکتر موسوی', has_schedule: hasSchedule }] } });
|
||||||
|
}
|
||||||
|
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'no_schedule' } });
|
||||||
|
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||||
|
if (url.includes('/api/v1/resources')) return Promise.resolve({ success: true, data: [
|
||||||
|
{ uuid: 'r-7', name: 'کندلا', type_name: 'دستگاه لیزر', supervisor: { uuid: 'doc1', name: 'دکتر موسوی' } },
|
||||||
|
] });
|
||||||
|
return Promise.resolve({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => get.mockReset());
|
||||||
|
|
||||||
|
it('پیام «ساعت کاری تنظیم نشده است» و منابعِ قابل رزرو را نشان میدهد', async () => {
|
||||||
|
mockDoctor(false);
|
||||||
|
renderWithProviders(<AppointmentsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('ساعت کاری تنظیم نشده است')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole('button', { name: 'کندلا' }).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeInTheDocument();
|
||||||
|
// لینک تنظیمات به صفحهٔ خودِ پزشک میرود، نه به تنظیمات کلینیک.
|
||||||
|
expect(screen.getByRole('link', { name: 'تنظیم ساعت کاری' })).toHaveAttribute('href', '/admin/appointment-settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('برنامهٔ کاری که باشد، تایملاین سرِ جایش میماند', async () => {
|
||||||
|
mockDoctor(true);
|
||||||
|
renderWithProviders(<AppointmentsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('برنامهٔ نوبتدهی ثبت نشده')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('ساعت کاری تنظیم نشده است')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** منابعِ کلینیک در محیط کلینیکاند؛ «نیست» با «جای دیگر است» اشتباه نشود. */
|
||||||
|
it('در مطب شخصی، محیطهای کلینیکیِ دیگر یادآوری میشوند', async () => {
|
||||||
|
mockDoctor(false, [
|
||||||
|
{ type: 'doctor', db_uuid: 'doc1', name: 'مطب من', role: 'doctor' },
|
||||||
|
{ type: 'clinic', db_uuid: 'cl-1', name: 'مدیسا', role: 'doctor' },
|
||||||
|
]);
|
||||||
|
get.mockImplementation((url?: string) => {
|
||||||
|
if (typeof url !== 'string') return Promise.resolve({ success: true, data: [] });
|
||||||
|
if (url.includes('/my/clinic-doctors')) return Promise.resolve({ success: true, data: { data: [{ uuid: 'doc1', name: 'دکتر موسوی', has_schedule: false }] } });
|
||||||
|
if (url.includes('today-stats')) return Promise.resolve({ success: true, data: { total: 0, completed: 0, waiting: 0, cancelled: 0 } });
|
||||||
|
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||||
|
return Promise.resolve({ success: true, data: [] }); // منبعی در محیط شخصی نیست
|
||||||
|
});
|
||||||
|
renderWithProviders(<AppointmentsPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/مدیسا/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'تغییر محیط کاری' })).toHaveAttribute('href', '/admin/select-context');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -215,16 +215,18 @@ export default function AppointmentsPage() {
|
|||||||
|
|
||||||
// ── Clinic doctors (authoritative list for tabs)
|
// ── Clinic doctors (authoritative list for tabs)
|
||||||
const clinicDoctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string; has_schedule?: boolean }[] }>>({
|
const clinicDoctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string; has_schedule?: boolean }[] }>>({
|
||||||
queryKey: ['clinic-doctors', dbUuid, isSecretary, isClinic],
|
queryKey: ['clinic-doctors', dbUuid, isSecretary, isClinic, isDoctor],
|
||||||
// منشی و کلینیک از اندپوینتِ احرازشده میگیرند: هم فقط پزشکانِ مجاز را میدهد و هم
|
// منشی، کلینیک و خودِ پزشک از اندپوینتِ احرازشده میگیرند: هم فقط پزشکانِ مجاز را
|
||||||
// `has_schedule` را، که مبنای ساختن تب است. ادمین از لیستِ کلینیکِ انتخابشده
|
// میدهد و هم `has_schedule`ِ همین محیط را. پزشک هم لازمش دارد — بدون آن،
|
||||||
// میخواند (آن اندپوینت نقش ادمین را پوشش نمیدهد) و آنجا فلگ نمیآید.
|
// پروفایل خودش نمیداند که «ساعت کاری تنظیم نشده» و فقط تایملاین خالی میبیند.
|
||||||
|
// ادمین از لیستِ کلینیکِ انتخابشده میخواند (آن اندپوینت نقش ادمین را پوشش
|
||||||
|
// نمیدهد) و آنجا فلگ نمیآید.
|
||||||
queryFn: () => api.get(
|
queryFn: () => api.get(
|
||||||
isSecretary || isClinic
|
isSecretary || isClinic || isDoctor
|
||||||
? '/api/v1/my/clinic-doctors'
|
? '/api/v1/my/clinic-doctors'
|
||||||
: `/api/v1/clinic/doctor-list/${dbUuid}`,
|
: `/api/v1/clinic/doctor-list/${dbUuid}`,
|
||||||
),
|
),
|
||||||
enabled: isSecretary || (isClinic && !!dbUuid),
|
enabled: isSecretary || isDoctor || (isClinic && !!dbUuid),
|
||||||
});
|
});
|
||||||
const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? [];
|
const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? [];
|
||||||
|
|
||||||
@@ -247,6 +249,34 @@ export default function AppointmentsPage() {
|
|||||||
/** پزشکِ انتخابشده برنامهٔ هفتگی ندارد ⇒ تایملاین جای خود را به راهنما میدهد. */
|
/** پزشکِ انتخابشده برنامهٔ هفتگی ندارد ⇒ تایملاین جای خود را به راهنما میدهد. */
|
||||||
const doctorHasNoSchedule = selectedDoctor?.hasSchedule === false;
|
const doctorHasNoSchedule = selectedDoctor?.hasSchedule === false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* صفحهٔ تنظیماتِ نوبتدهی که همین کاربر واقعاً میتواند بازش کند.
|
||||||
|
*
|
||||||
|
* مالک کلینیک و منشیِ محیط کلینیک از تنظیمات کلینیک وارد میشوند (با تبِ همان
|
||||||
|
* پزشک)؛ پزشک و منشیِ مطب شخصی از صفحهٔ خودِ پزشک که انتخابگر محیط دارد. بدون
|
||||||
|
* مجوز `appointment_settings.view` لینک اصلاً نمیآید — همان گیتی که `RoleRoute`
|
||||||
|
* روی هر دو مسیر میگذارد، وگرنه کلیک به داشبورد پرت میشد.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* محیطهای کلینیکیِ دیگرِ همین کاربر، وقتی خودش در مطب شخصی ایستاده.
|
||||||
|
*
|
||||||
|
* پزشکِ مهمانِ یک کلینیک، بهطور پیشفرض در محیط شخصی وارد پنل میشود و آنجا نه
|
||||||
|
* برنامهٔ کلینیک را میبیند نه منابعش (هر دو tenant-scopedاند) — بدون این راهنما،
|
||||||
|
* «چیزی نیست» با «جای دیگری است» اشتباه گرفته میشد.
|
||||||
|
*/
|
||||||
|
const availableContexts = useAuthStore(s => s.availableContexts);
|
||||||
|
const otherClinicNames = clinicUuid === null
|
||||||
|
? availableContexts.filter(c => c.type === 'clinic').map(c => c.name)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const apptSettingsHref = !can('appointment_settings', 'view')
|
||||||
|
? null
|
||||||
|
: isClinic || isClinicScopedSecretary
|
||||||
|
? `/admin/settings/appointment-settings?scope=doctors&doctor=${selectedDoctorUuid}`
|
||||||
|
: isDoctor || isSecretary
|
||||||
|
? '/admin/appointment-settings'
|
||||||
|
: null;
|
||||||
|
|
||||||
// سرویسهای موجود در نوبتهای امروز (برای فیلتر «سرویس مورد نظر...»).
|
// سرویسهای موجود در نوبتهای امروز (برای فیلتر «سرویس مورد نظر...»).
|
||||||
const serviceOptions = React.useMemo(() => {
|
const serviceOptions = React.useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
@@ -547,11 +577,8 @@ export default function AppointmentsPage() {
|
|||||||
/* بدون برنامهٔ هفتگی، خودِ پزشک اسلاتی ندارد؛ اما منابعِ تحت نظرش
|
/* بدون برنامهٔ هفتگی، خودِ پزشک اسلاتی ندارد؛ اما منابعِ تحت نظرش
|
||||||
تقویم مستقل دارند و همچنان قابل نوبتدهیاند. */
|
تقویم مستقل دارند و همچنان قابل نوبتدهیاند. */
|
||||||
<NoScheduleNotice
|
<NoScheduleNotice
|
||||||
settingsHref={
|
settingsHref={apptSettingsHref}
|
||||||
isClinic || isClinicScopedSecretary
|
otherClinicNames={otherClinicNames}
|
||||||
? `/admin/settings/appointment-settings?scope=doctors&doctor=${selectedDoctorUuid}`
|
|
||||||
: isDoctor || isSecretary ? '/admin/appointment-settings' : null
|
|
||||||
}
|
|
||||||
resources={supervisedResources}
|
resources={supervisedResources}
|
||||||
canCreate={!isRepresentation && canCreateAppt}
|
canCreate={!isRepresentation && canCreateAppt}
|
||||||
onBookResource={setBookingResource}
|
onBookResource={setBookingResource}
|
||||||
@@ -642,8 +669,10 @@ export default function AppointmentsPage() {
|
|||||||
* منبع، زیرمجموعهٔ پزشک است ولی تقویمش مستقل؛ پس نبودِ ساعت کاریِ پزشک نوبتدهیِ
|
* منبع، زیرمجموعهٔ پزشک است ولی تقویمش مستقل؛ پس نبودِ ساعت کاریِ پزشک نوبتدهیِ
|
||||||
* منابعش را متوقف نمیکند و همینجا میانبُرِ رزروشان میآید.
|
* منابعش را متوقف نمیکند و همینجا میانبُرِ رزروشان میآید.
|
||||||
*/
|
*/
|
||||||
function NoScheduleNotice({ settingsHref, resources, canCreate, onBookResource, onOpenResource }: {
|
function NoScheduleNotice({ settingsHref, otherClinicNames, resources, canCreate, onBookResource, onOpenResource }: {
|
||||||
settingsHref: string | null;
|
settingsHref: string | null;
|
||||||
|
/** کلینیکهایی که کاربر عضوشان است ولی الان در محیطشان نیست. */
|
||||||
|
otherClinicNames: string[];
|
||||||
resources: ClinicResource[];
|
resources: ClinicResource[];
|
||||||
canCreate: boolean;
|
canCreate: boolean;
|
||||||
onBookResource: (r: ClinicResource) => void;
|
onBookResource: (r: ClinicResource) => void;
|
||||||
@@ -661,6 +690,21 @@ function NoScheduleNotice({ settingsHref, resources, canCreate, onBookResource,
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{resources.length === 0 && otherClinicNames.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 18, paddingTop: 16, borderTop: '1px solid var(--border)',
|
||||||
|
fontSize: 12.5, color: 'var(--text-2)', lineHeight: 2,
|
||||||
|
}}>
|
||||||
|
شما الان در محیط «مطب شخصی» هستید. برنامهٔ کاری و منابعِ
|
||||||
|
{' '}{otherClinicNames.map(n => `«${n}»`).join('، ')}{' '}
|
||||||
|
در محیط همان کلینیک تعریف میشوند.
|
||||||
|
<br />
|
||||||
|
<Link to="/admin/select-context" className="btn secondary sm" style={{ display: 'inline-flex', marginTop: 8 }}>
|
||||||
|
تغییر محیط کاری
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{resources.length > 0 && (
|
{resources.length > 0 && (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginTop: 22, paddingTop: 18, borderTop: '1px solid var(--border)',
|
marginTop: 22, paddingTop: 18, borderTop: '1px solid var(--border)',
|
||||||
|
|||||||
Reference in New Issue
Block a user