diff --git a/assets/admin/pages/AppointmentBookingModal.test.tsx b/assets/admin/pages/AppointmentBookingModal.test.tsx index ddf5dca0..03992cbf 100644 --- a/assets/admin/pages/AppointmentBookingModal.test.tsx +++ b/assets/admin/pages/AppointmentBookingModal.test.tsx @@ -94,3 +94,40 @@ describe('NewAppointmentModal — جستجوی موبایل‌محور', () => { }))); }); }); + +describe('NewAppointmentModal — هزینه ویزیت', () => { + /** تعرفه را برای همان پزشکِ اسلات برمی‌گرداند؛ بقیهٔ GETها بیمارِ ناشناس. */ + function mockPricing(rials: number, requireVisit = false) { + get.mockImplementation((url: string) => + url.startsWith('/api/v1/insurance-pricing') + ? Promise.resolve({ success: true, data: { free_visit_price_rials: rials, require_visit_price: requireVisit } }) + : Promise.resolve({ success: true, data: { found: false } })); + } + + it('تعرفه را با doctor_uuid همان اسلات می‌خواند و در فیلد می‌گذارد', async () => { + mockPricing(2_120_000); + renderWithProviders( {}} onSuccess={() => {}} />); + + await waitFor(() => + expect(get).toHaveBeenCalledWith('/api/v1/insurance-pricing?doctor_uuid=doc1')); + // ۲٬۱۲۰٬۰۰۰ ریال = ۲۱۲٬۰۰۰ تومان + await waitFor(() => expect(screen.getByPlaceholderText('0')).toHaveValue('۲۱۲٬۰۰۰')); + }); + + it('بدون تعرفه، فیلد صفر می‌ماند و اختیاری است', async () => { + mockPricing(0); + renderWithProviders( {}} onSuccess={() => {}} />); + + await screen.findByText(/تعرفه‌ای ثبت نشده/); + expect(screen.getByPlaceholderText('0')).toHaveValue(''); + expect(screen.getByText('(اختیاری)')).toBeInTheDocument(); + }); + + it('با الزامی بودن هزینه ویزیت و مبلغ صفر، ثبت غیرفعال است', async () => { + mockPricing(0, true); + renderWithProviders( {}} onSuccess={() => {}} />); + + await screen.findByText('هزینه ویزیت الزامی است'); + expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled(); + }); +}); diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 2d572a16..81dc2a90 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -3,14 +3,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { PlusIcon, ChevronRightIcon, ChevronLeftIcon, CalendarDaysIcon, - AdjustmentsHorizontalIcon, + AdjustmentsHorizontalIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon, } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { PaginatedResponse, ApiResponse } from '../lib/api'; import type { Appointment } from '../types'; -import { formatDate, toGregorianDate, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial } from '../lib/utils'; +import { formatDate, toGregorianDate, formatTime, digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial } from '../lib/utils'; import PriceInput from '../components/ui/PriceInput'; +import Modal from '../components/ui/Modal'; import { useAuthStore } from '../stores/authStore'; import { useClinicContext } from '../hooks/useClinicContext'; import Pagination from '../components/ui/Pagination'; @@ -124,12 +125,25 @@ export function NewAppointmentModal({ // هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان، // API ریالی (visit_price_rials). بدون این مقدار، وقتی فلگ فعال است backend خطای ۴۲۲ می‌دهد. - const pricingQ = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({ + // + // قیمت باید از تنظیمات نوبت‌دهیِ *پزشکِ همین اسلات* بیاید، نه از entity کاربر جاری؛ + // منشی/کلینیک قیمت خودشان را ندارند و فیلد صفر می‌ماند. اگر دسترسی به تنظیمات آن + // پزشک نبود (۴۰۳)، به تنظیمات خودِ کاربر برمی‌گردیم تا فلگ الزامی‌بودن از دست نرود. + type Pricing = { data: { free_visit_price_rials: number; require_visit_price: boolean } }; + const doctorPricingQ = useQuery({ + queryKey: ['insurance-pricing', slot.doctor_uuid], + queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`), + retry: false, + }); + const selfPricingQ = useQuery({ queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'), + enabled: doctorPricingQ.isError, }); - const freeVisit = (pricingQ.data as any)?.data?.free_visit_price_rials ?? 0; - const requireVisit = (pricingQ.data as any)?.data?.require_visit_price ?? false; + const pricing = (doctorPricingQ.data ?? selfPricingQ.data) as any; + const freeVisit = pricing?.data?.free_visit_price_rials ?? 0; + const requireVisit = pricing?.data?.require_visit_price ?? false; + const pricingLoading = doctorPricingQ.isLoading || selfPricingQ.isLoading; const [visitPriceToman, setVisitPriceToman] = useState(0); const [visitPriceTouched, setVisitPriceTouched] = useState(false); useEffect(() => { @@ -182,15 +196,6 @@ export function NewAppointmentModal({ }, }); - const inputSx: React.CSSProperties = { - width: '100%', height: 38, padding: '0 10px', borderRadius: 'var(--r-sm)', - border: '1px solid var(--border)', background: 'var(--surface)', fontSize: 13, - boxSizing: 'border-box', - }; - const labelSx: React.CSSProperties = { - display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-2)', marginBottom: 6, - }; - // تغییر موبایل نتیجه‌ی جستجوی قبلی را باطل می‌کند تا کاربر دوباره جستجو کند. function onMobileChange(v: string) { // ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود). @@ -198,37 +203,63 @@ export function NewAppointmentModal({ if (lookup !== null) { setLookup(null); setPatientName(''); setNationalCode(''); } } + const priceHint = pricingLoading + ? 'در حال خواندن تعرفهٔ پزشک…' + : freeVisit > 0 + ? `تعرفهٔ نوبت‌دهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید` + : 'برای این پزشک تعرفه‌ای ثبت نشده — در صورت نیاز مبلغ را وارد کنید'; + return ( -
+ + + + + } + > + {/* اسلات انتخاب‌شده */}
e.stopPropagation()}> -
ثبت نوبت
-
- {serviceMode - ? slot.doctor_name - : `${slot.start_time} تا ${slot.end_time} — ${slot.doctor_name}`} -
- - {serviceMode && date && ( -
- -
+ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18, + padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)', + }}> + + + {serviceMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`} + + {!serviceMode && ( + + {slot.doctor_name} + )} +
-
- -
+ {serviceMode && date && ( +
+ +
+ )} + +
+ +
+
onMobileChange(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && mobileValid && !search.isPending) search.mutate(); }} placeholder="مثال: 09123456789" - style={{ ...inputSx, direction: 'ltr' }} + style={{ direction: 'ltr' }} autoFocus /> - +
+ +
+
+ + {foundWithNationalCode && ( +
+ +
+
بیمار یافت شد
+
{lookup?.name}
+
کد ملی: {lookup?.national_code}
+ )} - {foundWithNationalCode && ( + {needsDetails && ( + <>
-
بیمار یافت شد
-
{lookup?.name}
-
کد ملی: {lookup?.national_code}
+ {lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'کاربری با این شماره یافت نشد — بیمار جدید:'}
- )} - - {needsDetails && ( - <> -
- {lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'کاربری با این شماره یافت نشد — بیمار جدید:'} -
-
- +
+ +
setPatientName(e.target.value)} placeholder="مثال: علی محمدی" - style={inputSx} />
-
- +
+
+ +
setNationalCode(digitsOnly(e.target.value, 10))} placeholder="کد ملی ۱۰ رقمی" - style={{ ...inputSx, direction: 'ltr' }} + style={{ direction: 'ltr' }} />
- - )} +
+ + )} -
- +
+ +
{ setVisitPriceToman(v); setVisitPriceTouched(true); }} - style={{ ...inputSx, direction: 'ltr' }} + suffix="تومان" /> - {requireVisit && visitPriceToman <= 0 && ( -
هزینه ویزیت الزامی است
- )}
- -
- + {requireVisit && visitPriceToman <= 0 + ? هزینه ویزیت الزامی است + : {priceHint}} + {freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && ( -
+ )}
-
+ ); }