From 251b45d80752e6bc9e35959ea28e6c398748cea0 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 7 Aug 2026 12:39:35 +0330 Subject: [PATCH] feat(admin): let the secretary pre-assign an operator when booking The backend already accepted staff_uuid on POST /api/v1/my/appointment; only the booking modal never sent it, so the pre-assignment half of the operator model had no way in. The field sits in step 1 next to the section select and is optional by design: left empty, the session stays in the shared queue that every allowed operator sees. Its placeholder says so rather than leaving the blank state unexplained. The section select is no longer the only combobox in the modal, so the tests target it by its label instead of by role. Co-Authored-By: Claude Opus 5 --- .../appointments/NewAppointmentModal.tsx | 29 +++++++++++++++++++ .../ResourceBookingModal.test.tsx | 26 +++++++++++++++-- docs/api/treatment.md | 15 +++++++++- 3 files changed, 67 insertions(+), 3 deletions(-) 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` که نبودِ رکورد را محدودیت حساب نمی‌کند. + +نتیجه همیشه به محیطِ خودِ پرسنل محدود است. + ### شروع جلسه رکوردِ هر ناحیهٔ پرونده یک بار ساخته می‌شود، پس فراخوانی دوباره ناحیهٔ تکراری نمی‌سازد.