diff --git a/assets/admin/components/appointments/NewAppointmentModal.tsx b/assets/admin/components/appointments/NewAppointmentModal.tsx index d027fce7..cbaa6c2c 100644 --- a/assets/admin/components/appointments/NewAppointmentModal.tsx +++ b/assets/admin/components/appointments/NewAppointmentModal.tsx @@ -4,10 +4,12 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { ChevronDownIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon, CpuChipIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../../lib/api'; +import type { ApiResponse } from '../../lib/api'; import { digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial, formatDate } from '../../lib/utils'; import { useAuthStore } from '../../stores/authStore'; import Modal from '../ui/Modal'; import PriceInput from '../ui/PriceInput'; +import SearchableSelect from '../ui/SearchableSelect'; import ServiceSlotPicker from './ServiceSlotPicker'; import type { ServicePick } from './ServiceSlotPicker'; import type { BookingService } from '../../hooks/useDoctorBookingServices'; @@ -57,6 +59,9 @@ export default function NewAppointmentModal({ // معیار جستجوی بیمار: کد ملی (پیش‌فرض) یا موبایل. const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national'); const [pick, setPick] = useState({ serviceUuids: [], durations: {}, slot: null }); + // اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز می‌گذارد، + // پر کردنش آن را از قبل به یک نفر می‌دهد. + const [staffUuid, setStaffUuid] = useState(''); const role = useAuthStore(s => s.primaryRole); // نوبتِ منبع فقط مسیر پنل را دارد؛ اندپوینت ادمین `resource_uuid` نمی‌شناسد. @@ -81,6 +86,13 @@ export default function NewAppointmentModal({ enabled: !!slot.doctor_uuid, retry: false, }); + const staffQ = useQuery>({ + queryKey: ['staff-list'], + queryFn: () => api.get('/api/v1/staff'), + enabled: pickerMode, + staleTime: 60_000, + }); + const selfPricingQ = useQuery({ queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'), @@ -145,6 +157,7 @@ export default function NewAppointmentModal({ patient_name: effectiveName, patient_national_code: effectiveNationalCode, ...(clinicUuid ? { clinic_uuid: clinicUuid } : {}), + ...(staffUuid ? { staff_uuid: staffUuid } : {}), ...(pickerMode ? { service_item_uuids: pick.serviceUuids } : {}), // منبع: مدت را سرور از سرویس‌های همین منبع می‌سازد، پس ساعت پایان حدس نیست. ...(resource ? { @@ -264,6 +277,22 @@ export default function NewAppointmentModal({ onSelect={setPick} clinicUuidOverride={clinicUuid} /> + + {/* اختیاری به‌عمد: پیش‌فرضِ سیستم صفِ مشترک است و منشی فقط وقتی دخالت + می‌کند که بیمار اپراتور مشخصی خواسته باشد. */} +
+ + ({ value: s.uuid, label: s.full_name }))} + value={staffUuid || null} + onChange={v => setStaffUuid(v ? String(v) : '')} + placeholder="در صف مشترک پرسنل بماند" + isLoading={staffQ.isLoading} + isClearable + height={40} + /> +
)} diff --git a/assets/admin/components/appointments/ResourceBookingModal.test.tsx b/assets/admin/components/appointments/ResourceBookingModal.test.tsx index 4da9e3f9..01141568 100644 --- a/assets/admin/components/appointments/ResourceBookingModal.test.tsx +++ b/assets/admin/components/appointments/ResourceBookingModal.test.tsx @@ -34,6 +34,9 @@ function mockApi() { if (url.includes('patient-lookup')) { return Promise.resolve({ success: true, data: { found: true, name: 'رضا رحیمی', mobile: '09120001307', national_code: '0012345678' } }); } + if (url === '/api/v1/staff') { + return Promise.resolve({ success: true, data: [{ uuid: 'st-1', full_name: 'پرسنل یک' }] }); + } if (url.includes('/service-slots')) { return Promise.resolve({ success: true, data: { total_duration_minutes: 25, @@ -46,7 +49,8 @@ function mockApi() { /** انتخاب بخش → سرویس → زمان، همان مسیرِ مودالِ طرح. */ async function pickServiceAndTime() { - fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); + // مودال حالا دو combobox دارد — بخش و اپراتور. سراغ بخش با لیبل خودش می‌رویم. + fireEvent.keyDown(screen.getByLabelText('بخش'), { key: 'ArrowDown' }); fireEvent.click(await screen.findByText('خدمات درمانگاه')); // ردیف سرویس یک checkbox است نه دکمه: انتخابش حالت دارد و باید برای screen reader // «انتخاب‌شده/نشده» اعلام شود. @@ -123,7 +127,8 @@ describe('مودال ثبت نوبتِ منبع', () => { expect(await screen.findByText(/یک سرویس انتخاب کنید/)).toBeInTheDocument(); - fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' }); + // مودال حالا دو combobox دارد — بخش و اپراتور. سراغ بخش با لیبل خودش می‌رویم. + fireEvent.keyDown(screen.getByLabelText('بخش'), { key: 'ArrowDown' }); fireEvent.click(await screen.findByText('خدمات درمانگاه')); fireEvent.click(await screen.findByRole('checkbox', { name: /کرایوتراپی/ })); @@ -159,6 +164,23 @@ describe('مودال ثبت نوبتِ منبع', () => { expect(screen.getByPlaceholderText('مثال: 09123456789')).toBeInTheDocument(); }); + it('اپراتور اختیاری است و فقط وقتی انتخاب شود staff_uuid می‌رود', async () => { + renderWithProviders( + {}} onSuccess={() => {}} />, + ); + + await pickServiceAndTime(); + + fireEvent.change(screen.getByPlaceholderText('کد ملی ۱۰ رقمی'), { target: { value: '0012345678' } }); + fireEvent.click(screen.getByRole('button', { name: 'جستجو' })); + await screen.findByText('بیمار یافت شد'); + + // بدون انتخاب اپراتور، جلسه باید در صفِ مشترک بماند — پس کلید اصلاً نرود. + fireEvent.click(screen.getByRole('button', { name: 'ثبت نوبت' })); + await waitFor(() => expect(post).toHaveBeenCalled()); + expect(post.mock.calls[0][1]).not.toHaveProperty('staff_uuid'); + }); + it('منبعِ بدون سرویس، پیام راهنما می‌دهد نه فهرست خالی', () => { renderWithProviders( {}} onSuccess={() => {}} />, diff --git a/docs/api/treatment.md b/docs/api/treatment.md index 6a20f67b..b4cea97d 100644 --- a/docs/api/treatment.md +++ b/docs/api/treatment.md @@ -275,7 +275,7 @@ single-session again. Idempotent: deleting a service that has no protocol still | Method | Path | کار | |---|---|---| -| GET | `/dashboard/staff/treatment-sessions` | جلسات امروزِ همین پرسنل | +| GET | `/dashboard/staff/treatment-sessions` | صفِ امروزِ همین پرسنل — پایین را ببینید | | GET | `/dashboard/staff/treatment-session/{uuid}` | جزئیات جلسه + نواحی + `devices` + `forms` | | POST | `/dashboard/staff/treatment-session/{uuid}/start` | شروع جلسه | | POST | `/dashboard/staff/treatment-session/{uuid}/finish` | اتمام جلسه | @@ -283,6 +283,19 @@ single-session again. Idempotent: deleting a service that has no protocol still | POST | `/dashboard/staff/session-area/{uuid}/complete` | ثبت خوانده‌های دستگاه | | POST | `/dashboard/staff/session-area/{uuid}/skip` | صرف‌نظر از ناحیه | +### «جلسات امروز من» یک صف است + +جلسهٔ امروز از سه راه به یک اپراتور می‌رسد: + +- جلسه‌ای که خودش برداشته — `TreatmentSession.performedBy` هنگام «شروع جلسه» ست می‌شود. +- نوبتی که منشی از قبل به او داده — `staff_uuid` هنگام ثبت نوبت. +- کارِ بی‌صاحبِ امروز، اگر پروتکلِ آن سرویس نامش را برده باشد. + +پروتکلی که هیچ پرسنلی برایش تعریف نشده یعنی همه مجازند، نه هیچ‌کس — همان قاعدهٔ +`ResourceServiceOffering` که نبودِ رکورد را محدودیت حساب نمی‌کند. + +نتیجه همیشه به محیطِ خودِ پرسنل محدود است. + ### شروع جلسه رکوردِ هر ناحیهٔ پرونده یک بار ساخته می‌شود، پس فراخوانی دوباره ناحیهٔ تکراری نمی‌سازد.