diff --git a/assets/admin/components/schedule/ScheduleSection.test.tsx b/assets/admin/components/schedule/ScheduleSection.test.tsx index 928c9dff..2d1755ef 100644 --- a/assets/admin/components/schedule/ScheduleSection.test.tsx +++ b/assets/admin/components/schedule/ScheduleSection.test.tsx @@ -21,7 +21,7 @@ const ADDRESSES: AddressData[] = [{ }]; /** پاسخ برنامهٔ هفتگی؛ فقط `meta` بین تست‌ها فرق می‌کند. */ -function mockSchedule(meta: Record, locked = false) { +function mockSchedule(meta: Record, locked = false, changeable = false) { get.mockImplementation((url?: string) => { if (typeof url === 'string' && url.includes('weekly-schedule')) { return Promise.resolve({ @@ -32,6 +32,7 @@ function mockSchedule(meta: Record, locked = false) { schedule: {}, meta: { online_booking_enabled: true, booking_window_value: 3, booking_window_unit: 'month', buffer_minutes: 0, ...meta }, booking_mode_locked: locked, + booking_mode_changeable: changeable, }, }); } @@ -77,3 +78,21 @@ describe('WeeklyScheduleTab — روش نوبت‌دهی', () => { expect(screen.queryByRole('button', { name: /نوبت‌دهی منبع‌محور/ })).toBeNull(); }); }); + +describe('WeeklyScheduleTab — قفل نوع نوبت‌دهی', () => { + it('برای کاربر عادی، نوعِ ثبت‌شده قفل است', async () => { + mockSchedule({ booking_mode: 'slot' }, true); + renderWithProviders(); + + expect(await screen.findByText('نوع نوبت‌دهی ثبت شده و دیگر قابل تغییر نیست.')).toBeInTheDocument(); + expect(screen.getByText('نوبت‌دهی سرویسی').closest('button')).toBeDisabled(); + }); + + it('برای ادمین، همان نوع قابل تغییر است و هشدارش را می‌گوید', async () => { + mockSchedule({ booking_mode: 'slot' }, true, true); + renderWithProviders(); + + expect(await screen.findByText(/فقط ادمین می‌تواند عوضش کند/)).toBeInTheDocument(); + expect(screen.getByText('نوبت‌دهی سرویسی').closest('button')).not.toBeDisabled(); + }); +}); diff --git a/assets/admin/components/schedule/ScheduleSection.tsx b/assets/admin/components/schedule/ScheduleSection.tsx index 34f69d36..24b73ff6 100644 --- a/assets/admin/components/schedule/ScheduleSection.tsx +++ b/assets/admin/components/schedule/ScheduleSection.tsx @@ -112,6 +112,8 @@ interface WeeklyScheduleData { locations?: AddressData[]; /** مکان‌هایی که همین کاربر حق دارد روی برنامه بنشاند. */ selectable_location_ids?: number[]; + /** آیا همین کاربر می‌تواند قفلِ نوع نوبت‌دهی را باز کند — امروز فقط ادمین پلتفرم. */ + booking_mode_changeable?: boolean; } // ── Persian (Jalali) date utilities ─────────────────────────────────────── @@ -606,6 +608,12 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly const [meta, setMeta] = useState(DEFAULT_BOOKING_META); // نوع نوبت‌دهی پس از اولین ثبت قفل می‌شود؛ confirmMode = دیالوگ هشدار قبل از ثبت اول. const [modeLocked, setModeLocked] = useState(false); + // قفل برای همه هست، کلیدش فقط دست ادمین. نوعِ ثبت‌شده هم نگه داشته می‌شود تا + // بدانیم کاربر واقعاً عوضش کرده یا فقط ذخیرهٔ معمولی است. + const [modeChangeable, setModeChangeable] = useState(false); + const [savedMode, setSavedMode] = useState(null); + // پیام واقعی سرور دربارهٔ نوبت‌های آینده؛ تا وقتی null است دیالوگ تأیید بسته می‌ماند. + const [modeChangeWarning, setModeChangeWarning] = useState(null); const [confirmMode, setConfirmMode] = useState(false); @@ -632,6 +640,8 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly if (d?.locations) setAllLocations(d.locations); if (d?.selectable_location_ids) setSelectableIds(d.selectable_location_ids.map(Number)); setModeLocked(!!d?.booking_mode_locked); + setModeChangeable(!!d?.booking_mode_changeable); + setSavedMode(d?.meta?.booking_mode ?? null); } else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) { setScheduleMap(EMPTY_NEW_SCHEDULE); setScheduleUuid(null); } @@ -665,22 +675,34 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly (scheduleMap[d.key]?.sessions ?? []).some(s => s.active && s.location_id === null) ); + const modeChanged = savedMode !== null && savedMode !== meta.booking_mode; + const saveMut = useMutation({ - mutationFn: () => { + mutationFn: ({ forceModeChange = false }: { forceModeChange?: boolean } = {}) => { if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد'); if (missingLocation) throw new Error('مکان مطب برای همه بازه‌های فعال الزامی است'); + const body = { schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null, force_mode_change: forceModeChange }; return scheduleUuid - ? api.patch>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null }) - : api.post>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap, meta, clinic_uuid: clinicUuid ?? null }); + ? api.patch>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, body) + : api.post>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, ...body }); }, onSuccess: (res) => { const d: WeeklyScheduleData = res?.data?.data ?? res?.data; if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid); setModeLocked(true); // پس از ثبت، نوع نوبت‌دهی قفل می‌شود + setSavedMode(meta.booking_mode); toast.success('برنامه هفتگی ذخیره شد'); qc.invalidateQueries({ queryKey: ['doctor-schedule', doctorUuid, clinicUuid ?? null] }); }, - onError: (e: Error) => toast.error(e.message), + onError: (e: Error) => { + // سرور تغییرِ نوعِ ثبت‌شده را بار اول رد می‌کند و تعداد نوبت‌های فعال آینده را + // می‌گوید. همان جمله در دیالوگ تأیید نشان داده می‌شود، نه یک متن حدسی. + if (e instanceof ApiError && e.status === 422 && modeChanged) { + setModeChangeWarning(e.message); + return; + } + toast.error(e.message); + }, }); const setDaySessions = (key: string, sessions: SessionConfig[]) => @@ -785,13 +807,13 @@ export function WeeklyScheduleTab({ doctorUuid, clinicUuid, addresses, readOnly