feat: Enhance appointment management by decoupling online booking toggle for admin context

- Introduced management mode for appointment slots, allowing doctors, admins, and clinic managers to view and book slots regardless of the online booking status.
- Updated SlotCalculatorService to accept a management context parameter, bypassing online booking restrictions.
- Modified appointment-related endpoints to handle management context and ensure proper authorization checks.
- Added tests to verify that management users can access slots even when online booking is disabled, while public users are still restricted.
- Improved documentation for API endpoints to reflect new management parameters and behaviors.
This commit is contained in:
hamed
2026-07-22 16:43:56 +03:30
parent 5507b42fd8
commit ed516c81a8
16 changed files with 658 additions and 83 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
@@ -43,4 +43,43 @@ describe('AppointmentSettingsPage', () => {
expect(await screen.findByText('قیمت ویزیت آزاد')).toBeInTheDocument();
expect(screen.getByText('این بخش فقط برای پزشک در دسترس است.')).toBeInTheDocument();
});
it('پزشک عضو کلینیک بدون مطب شخصی: پیش‌فرض روی کلینیک، بدون پیام «ابتدا آدرس مطب»', async () => {
get.mockImplementation((url: string) => {
if (url === '/api/v1/doctor/doc-1')
return Promise.resolve({ success: true, data: { data: { uuid: 'doc-1', clinics: [{ uuid: 'clinicX', name: 'کلینیک الف' }] } } });
// مطب شخصی مکانی ندارد → پیش‌فرض باید کلینیک شود
if (url === '/api/v1/appointment-settings/available-locations/doc-1')
return Promise.resolve({ success: true, data: { data: [] } });
if (url.includes('available-locations/doc-1?clinic_uuid=clinicX'))
return Promise.resolve({ success: true, data: { data: [{ id: '5', uuid: 'addr5', type: 'clinic', clinic_id: '9', clinic_name: 'کلینیک الف' }] } });
if (url.includes('/weekly-schedule')) return Promise.resolve({ success: true, data: { data: null } });
return Promise.resolve({ success: true, data: {} });
});
renderWithProviders(<AppointmentSettingsPage />, { route: '/admin/appointment-settings' });
expect(await screen.findByText('محیط نوبت‌دهی')).toBeInTheDocument();
await waitFor(() =>
expect(get).toHaveBeenCalledWith(expect.stringContaining('available-locations/doc-1?clinic_uuid=clinicX')),
);
expect(screen.queryByText('ابتدا آدرس مطب را ثبت کنید')).not.toBeInTheDocument();
});
it('پزشک بدون کلینیک: انتخابگر محیط نمایش داده نمی‌شود', async () => {
get.mockImplementation((url: string) => {
if (url === '/api/v1/doctor/doc-1')
return Promise.resolve({ success: true, data: { data: { uuid: 'doc-1', clinics: [] } } });
if (url === '/api/v1/appointment-settings/available-locations/doc-1')
return Promise.resolve({ success: true, data: { data: [] } });
if (url.includes('/weekly-schedule')) return Promise.resolve({ success: true, data: { data: null } });
return Promise.resolve({ success: true, data: {} });
});
renderWithProviders(<AppointmentSettingsPage />, { route: '/admin/appointment-settings' });
await screen.findByText('قیمت ویزیت آزاد');
await waitFor(() => expect(get).toHaveBeenCalledWith('/api/v1/doctor/doc-1'));
expect(screen.queryByText('محیط نوبت‌دهی')).not.toBeInTheDocument();
});
});
+70 -3
View File
@@ -1,18 +1,70 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../stores/authStore';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import SettingsLayout from '../components/layout/SettingsLayout';
import FreeVisitPrice from '../components/FreeVisitPrice';
import { ScheduleSection } from '../components/schedule/ScheduleSection';
import type { AddressData } from '../components/schedule/ScheduleSection';
import SearchableSelect from '../components/ui/SearchableSelect';
const PERSONAL = 'personal';
/**
* مدیریت نوبت دهی — the doctor's appointment settings: visit price and the full
* weekly/overrides/holidays schedule (moved here from the doctor profile). The
* schedule is now managed exclusively from this page.
* weekly/overrides/holidays schedule.
*
* یک پزشک می‌تواند هم مطب شخصی داشته باشد و هم عضو یک/چند کلینیک باشد. هر محیط
* برنامهٔ نوبت‌دهی مستقل خودش را دارد (schedule per-context با clinic_id). پزشک عضو
* کلینیک آدرس مستقل ثبت نمی‌کند و از Location همان کلینیک استفاده می‌کند؛ پس اگر
* بیش از یک محیط داشته باشد، یک انتخابگر محیط نمایش داده می‌شود تا برنامهٔ همان
* محیط را مدیریت کند. پیش‌فرض روی محیطی می‌رود که مکان فعال دارد.
*/
export default function AppointmentSettingsPage() {
const doctorUuid = useAuthStore((s) => s.doctorUuid);
const dbUuid = useAuthStore((s) => s.dbUuid);
const uuid = doctorUuid ?? dbUuid ?? undefined;
// کلینیک‌هایی که پزشک عضوشان است (منبع: پروفایل خود پزشک).
const profileQ = useQuery({
queryKey: ['doctor-clinics', uuid],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/doctor/${uuid}`),
enabled: !!uuid,
staleTime: 300_000,
});
// پاسخِ doctor دو لایه تو در تو است: success(['data' => [...]]) → data.data.
const clinics: { uuid: string; name: string }[] =
(profileQ.data?.data as any)?.data?.clinics ?? (profileQ.data?.data as any)?.clinics ?? [];
// آیا مطب شخصی مکان فعال دارد؟ برای انتخاب پیش‌فرضِ درست.
const personalLocationsQ = useQuery({
queryKey: ['available-locations', uuid, null],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/appointment-settings/available-locations/${uuid}`),
enabled: !!uuid,
});
const personalLocations: AddressData[] =
(personalLocationsQ.data?.data as any)?.data ?? personalLocationsQ.data?.data ?? [];
const options = useMemo(() => [
{ value: PERSONAL, label: 'مطب شخصی' },
...clinics.map((c) => ({ value: c.uuid, label: c.name })),
], [clinics]);
const [picked, setPicked] = useState<string | null>(null);
// پیش‌فرض: مطب شخصی اگر مکان فعال دارد یا کلینیکی نیست؛ وگرنه اولین کلینیک —
// تا پزشکِ عضوِ کلینیکِ بدونِ مطب شخصی پیام «ابتدا آدرس مطب را ثبت کنید» نبیند.
const defaultContext = useMemo(() => {
if (personalLocations.length > 0 || clinics.length === 0) return PERSONAL;
return clinics[0].uuid;
}, [personalLocations.length, clinics]);
const selected = picked ?? defaultContext;
const clinicUuid = selected === PERSONAL ? null : selected;
const ready = !profileQ.isLoading && !personalLocationsQ.isLoading;
return (
<SettingsLayout active="appointment">
<div
@@ -27,8 +79,23 @@ export default function AppointmentSettingsPage() {
<div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>
این بخش فقط برای پزشک در دسترس است.
</div>
) : !ready ? (
<div className="space-y-2">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
) : (
<ScheduleSection doctorUuid={uuid} />
<>
{clinics.length > 0 && (
<div className="mb-4" style={{ maxWidth: 320 }}>
<label id="appt-context-label" className="block text-xs mb-1" style={{ color: 'var(--text-3)' }}>محیط نوبتدهی</label>
<SearchableSelect
options={options}
value={selected}
onChange={(v) => setPicked(v == null ? PERSONAL : String(v))}
ariaLabelledBy="appt-context-label"
/>
</div>
)}
<ScheduleSection doctorUuid={uuid} clinicUuid={clinicUuid} />
</>
)}
</div>
</SettingsLayout>
+2 -2
View File
@@ -479,7 +479,7 @@ export default function AppointmentsPage() {
// مطب شخصی خوانده می‌شود. محل از booking-locations همان پزشک انتخاب می‌شود.
const adminLocationsQuery = useQuery<ApiResponse<any>>({
queryKey: ['booking-locations', selectedDoctorUuid],
queryFn: () => api.get(`/api/v1/appointment-booking-locations/${selectedDoctorUuid}`),
queryFn: () => api.get(`/api/v1/appointment-booking-locations/${selectedDoctorUuid}?management=1`),
enabled: isAdmin && !!selectedDoctorUuid,
});
const adminLocations: any[] = (adminLocationsQuery.data?.data as any)?.booking_locations ?? EMPTY_ARR;
@@ -496,7 +496,7 @@ export default function AppointmentsPage() {
const slotsQuery = useQuery<ApiResponse<any>>({
queryKey: slotsQueryKey,
queryFn: () => api.get(
`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}` +
`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}&management=1` +
(effectiveClinicUuid ? `&clinic_uuid=${encodeURIComponent(effectiveClinicUuid)}` : '')
),
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,