feat(NewAppointmentModal): implement pricing retrieval and validation for visit costs
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user