feat(NewAppointmentModal): implement pricing retrieval and validation for visit costs

This commit is contained in:
hamed
2026-07-19 10:58:04 +03:30
parent 088e1348b5
commit 15366788d5
2 changed files with 172 additions and 88 deletions
@@ -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(<NewAppointmentModal slot={slot} onClose={() => {}} 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(<NewAppointmentModal slot={slot} onClose={() => {}} onSuccess={() => {}} />);
await screen.findByText(/تعرفه‌ای ثبت نشده/);
expect(screen.getByPlaceholderText('0')).toHaveValue('');
expect(screen.getByText('(اختیاری)')).toBeInTheDocument();
});
it('با الزامی بودن هزینه ویزیت و مبلغ صفر، ثبت غیرفعال است', async () => {
mockPricing(0, true);
renderWithProviders(<NewAppointmentModal slot={slot} onClose={() => {}} onSuccess={() => {}} />);
await screen.findByText('هزینه ویزیت الزامی است');
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
});
});
+135 -88
View File
@@ -3,14 +3,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { import {
PlusIcon, ChevronRightIcon, ChevronLeftIcon, CalendarDaysIcon, PlusIcon, ChevronRightIcon, ChevronLeftIcon, CalendarDaysIcon,
AdjustmentsHorizontalIcon, AdjustmentsHorizontalIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '../lib/api'; import { api } from '../lib/api';
import type { PaginatedResponse, ApiResponse } from '../lib/api'; import type { PaginatedResponse, ApiResponse } from '../lib/api';
import type { Appointment } from '../types'; 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 PriceInput from '../components/ui/PriceInput';
import Modal from '../components/ui/Modal';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { useClinicContext } from '../hooks/useClinicContext'; import { useClinicContext } from '../hooks/useClinicContext';
import Pagination from '../components/ui/Pagination'; import Pagination from '../components/ui/Pagination';
@@ -124,12 +125,25 @@ export function NewAppointmentModal({
// هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان، // هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان،
// API ریالی (visit_price_rials). بدون این مقدار، وقتی فلگ فعال است backend خطای ۴۲۲ می‌دهد. // 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<Pricing>({
queryKey: ['insurance-pricing', slot.doctor_uuid],
queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`),
retry: false,
});
const selfPricingQ = useQuery<Pricing>({
queryKey: ['insurance-pricing'], queryKey: ['insurance-pricing'],
queryFn: () => api.get('/api/v1/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 pricing = (doctorPricingQ.data ?? selfPricingQ.data) as any;
const requireVisit = (pricingQ.data as any)?.data?.require_visit_price ?? false; 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 [visitPriceToman, setVisitPriceToman] = useState(0);
const [visitPriceTouched, setVisitPriceTouched] = useState(false); const [visitPriceTouched, setVisitPriceTouched] = useState(false);
useEffect(() => { 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) { function onMobileChange(v: string) {
// ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود). // ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود).
@@ -198,37 +203,63 @@ export function NewAppointmentModal({
if (lookup !== null) { setLookup(null); setPatientName(''); setNationalCode(''); } if (lookup !== null) { setLookup(null); setPatientName(''); setNationalCode(''); }
} }
const priceHint = pricingLoading
? 'در حال خواندن تعرفهٔ پزشک…'
: freeVisit > 0
? `تعرفهٔ نوبت‌دهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید`
: 'برای این پزشک تعرفه‌ای ثبت نشده — در صورت نیاز مبلغ را وارد کنید';
return ( return (
<div style={{ <Modal
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 500, open
display: 'flex', alignItems: 'center', justifyContent: 'center', title="ثبت نوبت"
}} onClick={onClose}> size="sm"
onClose={onClose}
footer={
<>
<button className="btn ghost" onClick={onClose}>انصراف</button>
<button
className="btn primary"
onClick={() => mutation.mutate()}
disabled={!isValid || mutation.isPending}
>
{mutation.isPending ? 'در حال ثبت…' : 'ثبت نوبت'}
</button>
</>
}
>
{/* اسلات انتخاب‌شده */}
<div style={{ <div style={{
background: 'var(--surface)', borderRadius: 'var(--r)', padding: 24, display: 'flex', alignItems: 'center', gap: 10, marginBottom: 18,
minWidth: 320, maxWidth: 400, width: '90vw', boxShadow: 'var(--shadow-lg)', padding: '10px 14px', borderRadius: 'var(--r-sm)', background: 'var(--primary-soft)',
}} onClick={e => e.stopPropagation()}> }}>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 4 }}>ثبت نوبت</div> <ClockIcon style={{ width: 18, height: 18, color: 'var(--primary-700)', flexShrink: 0 }} />
<div style={{ fontSize: 13, color: 'var(--text-2)', marginBottom: 16 }}> <span style={{ fontSize: 13.5, color: 'var(--text)' }}>
{serviceMode {serviceMode ? slot.doctor_name : `${slot.start_time} تا ${slot.end_time}`}
? slot.doctor_name </span>
: `${slot.start_time} تا ${slot.end_time}${slot.doctor_name}`} {!serviceMode && (
</div> <span style={{ fontSize: 12.5, color: 'var(--text-2)', marginInlineStart: 'auto' }}>
{slot.doctor_name}
{serviceMode && date && ( </span>
<div style={{ marginBottom: 16 }}>
<ServiceSlotPicker
doctorUuid={slot.doctor_uuid}
date={date}
services={services}
onSelect={setPick}
clinicUuidOverride={clinicUuid}
/>
</div>
)} )}
</div>
<div style={{ marginBottom: 12 }}> {serviceMode && date && (
<label style={labelSx}>شماره موبایل بیمار *</label> <div style={{ marginBottom: 18 }}>
<div style={{ display: 'flex', gap: 8 }}> <ServiceSlotPicker
doctorUuid={slot.doctor_uuid}
date={date}
services={services}
onSelect={setPick}
clinicUuidOverride={clinicUuid}
/>
</div>
)}
<div className="field-block" style={{ marginBottom: 14 }}>
<label>شماره موبایل بیمار <span className="req">*</span></label>
<div style={{ display: 'flex', gap: 8 }}>
<div className="field" style={{ flex: 1 }}>
<input <input
type="tel" type="tel"
inputMode="numeric" inputMode="numeric"
@@ -237,48 +268,59 @@ export function NewAppointmentModal({
onChange={e => onMobileChange(e.target.value)} onChange={e => onMobileChange(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && mobileValid && !search.isPending) search.mutate(); }} onKeyDown={e => { if (e.key === 'Enter' && mobileValid && !search.isPending) search.mutate(); }}
placeholder="مثال: 09123456789" placeholder="مثال: 09123456789"
style={{ ...inputSx, direction: 'ltr' }} style={{ direction: 'ltr' }}
autoFocus autoFocus
/> />
<button </div>
className="btn sm" <button
onClick={() => search.mutate()} className="btn soft"
disabled={!mobileValid || search.isPending} onClick={() => search.mutate()}
style={{ whiteSpace: 'nowrap' }} disabled={!mobileValid || search.isPending}
> style={{ whiteSpace: 'nowrap' }}
{search.isPending ? '...' : 'جستجو'} >
</button> <MagnifyingGlassIcon style={{ width: 16, height: 16 }} />
{search.isPending ? '...' : 'جستجو'}
</button>
</div>
</div>
{foundWithNationalCode && (
<div style={{
marginBottom: 16, padding: '10px 14px', borderRadius: 'var(--r-sm)',
background: 'var(--success-bg)', fontSize: 13,
display: 'flex', alignItems: 'center', gap: 10,
}}>
<CheckCircleIcon style={{ width: 20, height: 20, color: 'var(--success)', flexShrink: 0 }} />
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 700, color: 'var(--success)', fontSize: 12 }}>بیمار یافت شد</div>
<div style={{ fontWeight: 700, color: 'var(--text)' }}>{lookup?.name}</div>
<div style={{ color: 'var(--text-2)', fontSize: 12 }}>کد ملی: {lookup?.national_code}</div>
</div> </div>
</div> </div>
)}
{foundWithNationalCode && ( {needsDetails && (
<>
<div style={{ <div style={{
marginBottom: 16, padding: '10px 12px', borderRadius: 'var(--r-sm)', fontSize: 12.5, color: 'var(--text-2)', marginBottom: 12,
background: 'var(--success-bg)', border: '1px solid var(--success)', fontSize: 13, padding: '9px 12px', borderRadius: 'var(--r-sm)', background: 'var(--warning-bg)',
}}> }}>
<div style={{ fontWeight: 700, color: 'var(--success)', marginBottom: 2 }}>بیمار یافت شد</div> {lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'کاربری با این شماره یافت نشد — بیمار جدید:'}
<div style={{ color: 'var(--text)' }}>{lookup?.name}</div>
<div style={{ color: 'var(--text-2)', direction: 'ltr', textAlign: 'right' }}>کد ملی: {lookup?.national_code}</div>
</div> </div>
)} <div className="field-block" style={{ marginBottom: 14 }}>
<label>نام و نام خانوادگی بیمار <span className="req">*</span></label>
{needsDetails && ( <div className="field">
<>
<div style={{ fontSize: 12, color: 'var(--text-2)', marginBottom: 10 }}>
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'کاربری با این شماره یافت نشد — بیمار جدید:'}
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelSx}>نام و نام خانوادگی بیمار *</label>
<input <input
type="text" type="text"
value={patientName} value={patientName}
onChange={e => setPatientName(e.target.value)} onChange={e => setPatientName(e.target.value)}
placeholder="مثال: علی محمدی" placeholder="مثال: علی محمدی"
style={inputSx}
/> />
</div> </div>
<div style={{ marginBottom: 16 }}> </div>
<label style={labelSx}>کد ملی بیمار *</label> <div className="field-block" style={{ marginBottom: 14 }}>
<label>کد ملی بیمار <span className="req">*</span></label>
<div className="field">
<input <input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
@@ -287,38 +329,43 @@ export function NewAppointmentModal({
value={nationalCode} value={nationalCode}
onChange={e => setNationalCode(digitsOnly(e.target.value, 10))} onChange={e => setNationalCode(digitsOnly(e.target.value, 10))}
placeholder="کد ملی ۱۰ رقمی" placeholder="کد ملی ۱۰ رقمی"
style={{ ...inputSx, direction: 'ltr' }} style={{ direction: 'ltr' }}
/> />
</div> </div>
</> </div>
)} </>
)}
<div style={{ marginBottom: 16 }}> <div className="field-block">
<label style={labelSx}> <label>
هزینه ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>} هزینه ویزیت (تومان)
</label> {requireVisit ? <span className="req"> *</span> : <span className="opt">(اختیاری)</span>}
</label>
<div
className="field"
style={requireVisit && visitPriceToman <= 0 ? { borderColor: 'var(--danger)' } : undefined}
>
<PriceInput <PriceInput
value={visitPriceToman} value={visitPriceToman}
onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }} onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }}
style={{ ...inputSx, direction: 'ltr' }} suffix="تومان"
/> />
{requireVisit && visitPriceToman <= 0 && (
<div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 6 }}>هزینه ویزیت الزامی است</div>
)}
</div> </div>
{requireVisit && visitPriceToman <= 0
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}> ? <span className="field-err">هزینه ویزیت الزامی است</span>
<button className="btn sm" onClick={onClose}>انصراف</button> : <span className="field-hint">{priceHint}</span>}
{freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && (
<button <button
className="btn primary sm" type="button"
onClick={() => mutation.mutate()} className="btn ghost sm"
disabled={!isValid || mutation.isPending} style={{ marginTop: 8, alignSelf: 'flex-start' }}
onClick={() => { setVisitPriceToman(rialToToman(freeVisit)); setVisitPriceTouched(true); }}
> >
{mutation.isPending ? '...' : 'ثبت نوبت'} استفاده از تعرفهٔ پزشک
</button> </button>
</div> )}
</div> </div>
</div> </Modal>
); );
} }