diff --git a/assets/admin/components/appointments/ResourceBookingModal.test.tsx b/assets/admin/components/appointments/ResourceBookingModal.test.tsx deleted file mode 100644 index 828168af..00000000 --- a/assets/admin/components/appointments/ResourceBookingModal.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { renderWithProviders } from '../../test/utils'; - -vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); -vi.mock('../../lib/api', () => ({ - api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() }, - ApiError: class extends Error {}, -})); - -import { api } from '../../lib/api'; -import ResourceBookingModal from './ResourceBookingModal'; - -const get = api.get as ReturnType; -const post = api.post as ReturnType; - -const resource = { - uuid: 'r-laser', - name: 'لیزر CO2', - address_uuid: 'addr-1', - supervisor: { uuid: 'd-1', name: 'دکتر مرادی' }, -} as never; - -/** یکی از این دو وقت مالِ همین منبع است و دیگری مالِ دستگاه دیگری. */ -const slots = [ - { start: 1783859400, end: 1783861200, assignment: { device: [{ uuid: 'r-laser', name: 'لیزر CO2' }] } }, - { start: 1783863000, end: 1783864800, assignment: { device: [{ uuid: 'r-other', name: 'لیزر دیگر' }] } }, -]; - -beforeEach(() => { - get.mockReset(); - post.mockReset(); - get.mockImplementation((url: string) => { - if (url.includes('/services')) { - return Promise.resolve({ success: true, data: [ - { service_uuid: 's-1', service_name: 'لیزر صورت', active: true, effective_duration_minutes: 30 }, - { service_uuid: 's-2', service_name: 'سرویس غیرفعال', active: false, effective_duration_minutes: 20 }, - ] }); - } - if (url.includes('/my/clinic-doctors')) { - return Promise.resolve({ success: true, data: { data: [{ uuid: 'd-1', name: 'دکتر مرادی' }] } }); - } - return Promise.resolve({ success: true, data: [] }); - }); - post.mockImplementation((url: string) => { - if (url.includes('appointment-availability')) { - return Promise.resolve({ success: true, data: { plan: { total_minutes: 30, segments: [] }, slots, reason: null } }); - } - if (url.includes('appointment-hold')) { - return Promise.resolve({ success: true, data: { hold_uuid: 'h-1', assignment: {} } }); - } - return Promise.resolve({ success: true, data: { uuid: 'appt-1' } }); - }); -}); - -describe('ResourceBookingModal', () => { - /** فقط سرویس‌هایی که خودِ منبع ارائه می‌دهد — سرور هم بقیه را با ۴۲۲ رد می‌کند. */ - it('فقط سرویس‌های فعالِ همین منبع را می‌آورد', async () => { - renderWithProviders( - , - ); - - expect(await screen.findByText('ثبت نوبت — لیزر CO2')).toBeInTheDocument(); - const called = get.mock.calls.map((c: any[]) => c[0]); - expect(called.some((u: string) => u.includes('/api/v1/resource/r-laser/services'))).toBe(true); - }); - - /** - * هستهٔ منبع‌محور بودن: موتور خدمت‌محور جواب می‌دهد، پس وقتی که این منبع در آن - * پیشنهاد نشده باید حذف شود — نشان دادنش یعنی اپراتور چیزی بگیرد که ۴۰۹ می‌شود. - */ - it('فقط وقت‌هایی را نشان می‌دهد که موتور همین منبع را در آن‌ها داده', async () => { - const user = userEvent.setup(); - renderWithProviders( - , - ); - - await screen.findByText('ثبت نوبت — لیزر CO2'); - await user.click(await screen.findByText('سرویس را انتخاب کنید')); - await user.click(await screen.findByText(/لیزر صورت/)); - - await waitFor(() => expect(screen.getByText(/زمان‌های خالی این منبع/)).toBeInTheDocument()); - - // وقتِ منبعِ دیگر (۱۷۸۳۸۶۳۰۰۰) نباید دکمه‌ای داشته باشد. - const buttons = screen.getAllByRole('button').map((b) => b.textContent ?? ''); - const timeButtons = buttons.filter((t) => /\d{2}:\d{2}/.test(t)); - expect(timeButtons).toHaveLength(1); - }); - - /** - * پزشک از ناظرِ خودِ منبع می‌آید، نه از یک انتخابگر دیگر — ارتباط پزشک↔منبع یک جا - * تعریف شده و تکرارش اینجا یعنی دو منبعِ حقیقت. - */ - it('پزشک را از ناظرِ منبع می‌گیرد و انتخابگر پزشک ندارد', async () => { - const user = userEvent.setup(); - renderWithProviders( - , - ); - - await screen.findByText('ثبت نوبت — لیزر CO2'); - expect(screen.getByText(/پزشک ناظر: دکتر مرادی/)).toBeInTheDocument(); - expect(screen.queryByText('پزشک انجام‌دهنده')).toBeNull(); - - // بدون سرویس و زمان هنوز غیرفعال است. - expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled(); - - await user.click(await screen.findByText('سرویس را انتخاب کنید')); - await user.click(await screen.findByText(/لیزر صورت/)); - await waitFor(() => expect(screen.getByText(/زمان‌های خالی این منبع/)).toBeInTheDocument()); - - const timeBtn = screen.getAllByRole('button').find((b) => /\d{2}:\d{2}/.test(b.textContent ?? '')); - await user.click(timeBtn!); - - // سرویس + زمان + ناظر ⇒ حالا قابل ثبت است. - await waitFor(() => expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeEnabled()); - }); - - /** منبعی که ناظرش حذف شده نباید بی‌صدا غیرقابل‌ثبت بماند. */ - it('منبع بدون ناظر را با پیام روشن مسدود می‌کند', async () => { - const orphan = { uuid: 'r-x', name: 'لیزر یتیم', address_uuid: 'addr-1', supervisor: null } as never; - renderWithProviders( - , - ); - - expect(await screen.findByText(/این منبع پزشک ناظر ندارد/)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled(); - }); -}); diff --git a/assets/admin/components/appointments/ResourceBookingModal.tsx b/assets/admin/components/appointments/ResourceBookingModal.tsx deleted file mode 100644 index d2a36e2b..00000000 --- a/assets/admin/components/appointments/ResourceBookingModal.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import Modal from '../ui/Modal'; -import SearchableSelect from '../ui/SearchableSelect'; -import { api, type ApiResponse } from '../../lib/api'; -import { formatNumber } from '../../lib/utils'; -import { - REASON_LABELS, - useAvailabilitySearch, - useHold, - type AvailableSlot, -} from '../../hooks/useResourceBooking'; -import type { ClinicResource, ResourceServiceOffering } from '../../types'; - -const DAY = 86400; - -function hhmm(ts: number): string { - const d = new Date(ts * 1000); - return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; -} - -/** نقشی از تخصیص که این منبع در آن پیشنهاد شده — `null` یعنی این وقت مالِ منبع دیگری است. */ -function roleHolding(slot: AvailableSlot, resourceUuid: string): string | null { - for (const [role, resources] of Object.entries(slot.assignment)) { - if (resources.some((r) => r.uuid === resourceUuid)) return role; - } - return null; -} - -/** - * ثبت نوبت برای یک منبع مشخص — سرویس‌های خودِ منبع، بعد زمان، بعد پزشک مسئول. - * - * موتور همان `appointment-availability → hold → confirm` است که از قبل هست؛ تنها - * تفاوت این است که جستجو خدمت‌محور جواب می‌دهد و ما نتیجه را به وقت‌هایی تنگ می‌کنیم - * که موتور در آن‌ها **همین منبع** را پیشنهاد داده. وقتی که منبع در آن آزاد نبوده اصلاً - * نشان داده نمی‌شود، وگرنه اپراتور چیزی را انتخاب می‌کند که در `hold` خطای ۴۰۹ می‌گیرد. - * - * پزشک مسئول اجباری است: هر نوبت در این سیستم پزشک دارد و `confirm` بدونش کار نمی‌کند. - */ -export default function ResourceBookingModal({ resource, onClose, onBooked }: { - resource: ClinicResource; - onClose: () => void; - onBooked: () => void; -}) { - const { create, confirm } = useHold(); - - const [serviceUuid, setServiceUuid] = useState(''); - const [days, setDays] = useState('7'); - const [pickedSlot, setPickedSlot] = useState(null); - const [submitting, setSubmitting] = useState(false); - - // پزشکِ نوبت از ناظرِ خودِ منبع می‌آید، نه از یک انتخابگرِ دیگر: ارتباط پزشک و منبع - // یک جا تعریف شده (فرم منبع) و تکرارش اینجا یعنی دو منبعِ حقیقت. - const doctorUuid = resource.supervisor?.uuid ?? ''; - - // سرویس‌های همین منبع، نه کل کاتالوگ: منبعی که سرویسی را ارائه نمی‌دهد نباید - // در فهرست بیاید — سرور هم همان را با ۴۲۲ رد می‌کند. - const offeringsQuery = useQuery>({ - queryKey: ['resource-services', resource.uuid], - queryFn: () => api.get(`/api/v1/resource/${resource.uuid}/services`), - }); - const offerings = (offeringsQuery.data?.data ?? []).filter((o) => o.active); - - const range = useMemo(() => { - const from = Math.floor(Date.now() / 1000); - return { from, to: from + Number(days) * DAY }; - }, [days]); - - const { result, loading } = useAvailabilitySearch( - { serviceUuid, branchUuid: resource.address_uuid, from: range.from, to: range.to }, - !!serviceUuid, - ); - - const slots = useMemo( - () => (result?.slots ?? []).filter((s) => roleHolding(s, resource.uuid) !== null), - [result, resource.uuid], - ); - - const reasonText = result?.reason ? REASON_LABELS[result.reason] ?? result.reason : null; - const canSubmit = !!serviceUuid && !!pickedSlot && !!doctorUuid && !submitting; - // ناظر در API الزامی است؛ ردیف‌های قدیمی‌ای که پزشکشان حذف شده `supervisor: null` دارند. - const missingSupervisor = !resource.supervisor; - - const submit = async () => { - if (!pickedSlot) return; - setSubmitting(true); - - try { - // تخصیص همان چیزی است که موتور برای این وقت داده، با یک قید: نقشی که این منبع - // در آن آمده به خودش قفل می‌شود تا نوبت واقعاً روی همین دستگاه بنشیند. - const role = roleHolding(pickedSlot, resource.uuid); - const assignment = Object.fromEntries( - Object.entries(pickedSlot.assignment).map(([r, list]) => [ - r, - r === role ? [resource.uuid] : list.map((x) => x.uuid), - ]), - ); - - const held = await create.mutateAsync({ - service_uuid: serviceUuid, - branch_uuid: resource.address_uuid, - start: pickedSlot.start, - assignment, - }); - - await confirm.mutateAsync({ hold_uuid: held.data.hold_uuid, doctor_uuid: doctorUuid }); - onBooked(); - onClose(); - } finally { - setSubmitting(false); - } - }; - - return ( - - - - - } - > - {missingSupervisor && ( -

- این منبع پزشک ناظر ندارد و نوبتی برایش ثبت نمی‌شود — از «منابع» ناظرش را تعیین کنید. -

- )} - -
- - {offeringsQuery.isLoading ? ( -
- ) : offerings.length === 0 ? ( -

- برای این منبع سرویسی تعریف نشده است — از تب «سرویس‌ها»ی همین منبع اضافه کنید. -

- ) : ( - ({ - value: o.service_uuid, - label: o.effective_duration_minutes - ? `${o.service_name} · ${formatNumber(o.effective_duration_minutes)} دقیقه` - : o.service_name, - }))} - value={serviceUuid || null} - onChange={(v) => { setServiceUuid(v ? String(v) : ''); setPickedSlot(null); }} - placeholder="سرویس را انتخاب کنید" - height={40} - /> - )} -
- - {serviceUuid && ( -
- - { setDays(v ? String(v) : '7'); setPickedSlot(null); }} - height={40} - /> -
- )} - - {serviceUuid && ( -
- زمان‌های خالی این منبع - {loading ? ( -
در حال محاسبه…
- ) : slots.length === 0 ? ( -

- {reasonText ?? 'برای این منبع در این بازه وقت آزادی نیست — بازه را بزرگ‌تر کنید.'} -

- ) : ( -
- {slots.slice(0, 40).map((s) => { - const active = pickedSlot?.start === s.start; - return ( - - ); - })} -
- )} -
- )} - -
-
-
پزشک ناظر: {resource.supervisor?.name ?? '—'}
-
- نوبت به نام همین پزشک ثبت می‌شود. برای تغییرش، ناظرِ منبع را در «منابع» ویرایش کنید. -
-
-
- - ); -}