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:
@@ -99,7 +99,7 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
const svcSlotsQ = useQuery<ApiResponse<any>>({
|
||||
queryKey: ['drawer-service-slots', doctorUuid, date, serviceUuids, clinicUuid],
|
||||
queryFn: () => api.get(
|
||||
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
|
||||
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
|
||||
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
|
||||
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
|
||||
),
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function ServiceSlotPicker({
|
||||
const slotsQ = useQuery<ApiResponse<any>>({
|
||||
queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids, durations, clinicUuid],
|
||||
queryFn: () => api.get(
|
||||
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
|
||||
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
|
||||
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
|
||||
+ durationsQs
|
||||
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : ''),
|
||||
|
||||
@@ -50,6 +50,16 @@ const APPOINTMENTS_CHILDREN: SubItem[] = [
|
||||
{ to: "/admin/appointments/new", label: "افزودن نوبت", icon: PlusIcon },
|
||||
];
|
||||
|
||||
/**
|
||||
* همان زیرمنوها + «رزرو نوبت». برای نقشهایی که مالک برنامهٔ نوبتدهیاند
|
||||
* (ادمین/کلینیک/پزشک) تا همهٔ عملیات نوبت زیر یک بخش جمع شود. پزشکِ مهمانِ کلینیک
|
||||
* از نسخهٔ بدون رزرو (`APPOINTMENTS_CHILDREN`) استفاده میکند.
|
||||
*/
|
||||
const APPOINTMENTS_CHILDREN_WITH_RESERVE: SubItem[] = [
|
||||
...APPOINTMENTS_CHILDREN,
|
||||
{ to: "/admin/appointments/reserve", label: "رزرو نوبت", icon: ArchiveBoxIcon },
|
||||
];
|
||||
|
||||
function buildSections(
|
||||
primaryRole: string | null,
|
||||
dbUuid: string | null,
|
||||
@@ -117,12 +127,7 @@ function buildSections(
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتها",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
},
|
||||
{
|
||||
to: "/admin/appointments/reserve",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتهای رزرو",
|
||||
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
|
||||
},
|
||||
{
|
||||
to: "/admin/payments",
|
||||
@@ -232,12 +237,7 @@ function buildSections(
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتها",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
},
|
||||
{
|
||||
to: "/admin/appointments/reserve",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتهای رزرو",
|
||||
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
|
||||
},
|
||||
{
|
||||
to: "/admin/patients",
|
||||
@@ -377,12 +377,7 @@ function buildSections(
|
||||
to: "/admin/appointments",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتها",
|
||||
children: APPOINTMENTS_CHILDREN,
|
||||
},
|
||||
{
|
||||
to: "/admin/appointments/reserve",
|
||||
icon: CalendarDaysIcon,
|
||||
label: "نوبتهای رزرو",
|
||||
children: APPOINTMENTS_CHILDREN_WITH_RESERVE,
|
||||
},
|
||||
{
|
||||
to: "/admin/patients",
|
||||
|
||||
@@ -500,6 +500,28 @@ function SessionEditor({ session, onChange, onRemove, addresses, serviceMode = f
|
||||
const withClinic = (url: string, clinicUuid?: string | null): string =>
|
||||
clinicUuid ? `${url}${url.includes('?') ? '&' : '?'}clinic_uuid=${encodeURIComponent(clinicUuid)}` : url;
|
||||
|
||||
/**
|
||||
* وقتی محیط انتخابشده مکانِ نوبت فعالی ندارد. متن به محیط بستگی دارد: پزشک عضو
|
||||
* کلینیک آدرس مستقل ثبت نمیکند، پس در محیط کلینیک نباید «ابتدا آدرس مطب را ثبت
|
||||
* کنید» ببیند — آدرس از تنظیمات همان کلینیک میآید.
|
||||
*/
|
||||
function NoLocationsNotice({ clinicUuid }: { clinicUuid?: string | null }) {
|
||||
const [title, hint] = clinicUuid
|
||||
? ['این کلینیک هنوز مکان نوبتدهی فعال ندارد', 'ابتدا در تنظیمات کلینیک یک آدرس فعال ثبت شود']
|
||||
: ['ابتدا آدرس مطب را ثبت کنید', 'برنامه کاری نیاز به حداقل یک مکان نوبت دارد'];
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 flex items-center justify-center">
|
||||
<MapPinIcon className="w-6 h-6 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{title}</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">{hint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly = false }: { doctorUuid: string; clinicUuid?: string | null; addresses: AddressData[]; readOnly?: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [scheduleMap, setScheduleMap] = useState<NewScheduleMap>(EMPTY_NEW_SCHEDULE);
|
||||
@@ -595,17 +617,7 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly
|
||||
<div className="space-y-3">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-12 rounded-xl skeleton" />)}</div>
|
||||
);
|
||||
|
||||
if (addresses.length === 0) return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 flex items-center justify-center">
|
||||
<MapPinIcon className="w-6 h-6 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">ابتدا آدرس مطب را ثبت کنید</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">برنامه کاری نیاز به حداقل یک مکان نوبت دارد</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (addresses.length === 0) return <NoLocationsNotice clinicUuid={clinicUuid} />;
|
||||
|
||||
// نمای فقطخواندنی برای نماینده: برنامهی هفتگی بهصورت متن، بدون فرم.
|
||||
if (readOnly) {
|
||||
@@ -1047,17 +1059,7 @@ function DateOverridesTab({ doctorUuid, clinicUuid, addresses, readOnly = false
|
||||
<div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-14 rounded-xl skeleton" />)}</div>
|
||||
);
|
||||
|
||||
if (addresses.length === 0) return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 flex items-center justify-center">
|
||||
<MapPinIcon className="w-6 h-6 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">ابتدا آدرس مطب را ثبت کنید</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">برنامه کاری نیاز به حداقل یک مکان نوبت دارد</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (addresses.length === 0) return <NoLocationsNotice clinicUuid={clinicUuid} />;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user