NewAppointmentModal and NewAppointmentDrawer never read the require-visit- price setting nor sent visit_price_rials, so booking failed with 422 "هزینه ویزیت الزامی است" when the flag was on. Add a visit-price field (prefilled from the free-visit price), require it when the flag is set, and include visit_price_rials in the POST payload, matching AppointmentCreatePage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
392 lines
21 KiB
TypeScript
392 lines
21 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||
import { toast } from 'sonner';
|
||
import { api } from '../lib/api';
|
||
import type { ApiResponse } from '../lib/api';
|
||
import Modal from './ui/Modal';
|
||
import PersianDateInput from './ui/PersianDateInput';
|
||
import PriceInput from './ui/PriceInput';
|
||
import SearchableSelect from './ui/SearchableSelect';
|
||
import { WalletChargeLink } from './AppointmentActions';
|
||
import { tehranWallClockToUnix, tomanToRial, rialToToman, toEnglishDigits, sanitizeMobileInput } from '../lib/utils';
|
||
|
||
interface Option { uuid: string; name?: string; full_name?: string }
|
||
interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string }
|
||
|
||
const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time);
|
||
const addMinutes = (time: string, min: number) => {
|
||
const [h, m] = time.split(':').map(Number);
|
||
const t = h * 60 + m + min;
|
||
return `${String(Math.floor(t / 60) % 24).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`;
|
||
};
|
||
|
||
/**
|
||
* اضافه کردن نوبت جدید (Figma add.pdf) — rich create form: patient
|
||
* search-or-new, بخش/سرویس/پرسنل, date + default-duration + start/end time,
|
||
* deposit toggle, status and notes. POSTs the extended /my/appointment.
|
||
*/
|
||
export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey, onClose, isReserve = false }: {
|
||
doctorUuid: string;
|
||
/** ISO Y-m-d — the currently viewed day. */
|
||
defaultDate: string;
|
||
queryKey: unknown[];
|
||
onClose: () => void;
|
||
/** true → «اضافه کردن نوبت رزرو» (day-level entry, no time fields). */
|
||
isReserve?: boolean;
|
||
}) {
|
||
const qc = useQueryClient();
|
||
|
||
// ── patient: pick an existing record or enter a new person ────────────────
|
||
const [patientSearch, setPatientSearch] = useState('');
|
||
const [pickedPatient, setPickedPatient] = useState<PatientRow | null>(null);
|
||
const [name, setName] = useState('');
|
||
const [mobile, setMobile] = useState('');
|
||
const [nationalCode, setNationalCode] = useState('');
|
||
|
||
const patientsQ = useQuery<ApiResponse<PatientRow[]>>({
|
||
queryKey: ['drawer-patients', patientSearch],
|
||
queryFn: () => api.get(`/api/v1/patients?search=${encodeURIComponent(patientSearch)}&limit=10`),
|
||
enabled: patientSearch.trim().length >= 2,
|
||
});
|
||
|
||
// ── service specs ──────────────────────────────────────────────────────────
|
||
const [sectionUuid, setSectionUuid] = useState('');
|
||
const [itemUuid, setItemUuid] = useState('');
|
||
const [staffUuid, setStaffUuid] = useState('');
|
||
|
||
// روش نوبتدهی پزشک: در حالت «سرویس» زمان از مدت سرویس محاسبه و پیشنهاد میشود.
|
||
const scheduleQ = useQuery<ApiResponse<any>>({
|
||
queryKey: ['drawer-schedule', doctorUuid],
|
||
queryFn: () => api.get(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`),
|
||
enabled: !!doctorUuid,
|
||
});
|
||
const bookingMode: 'slot' | 'service' =
|
||
((scheduleQ.data?.data as any)?.data?.meta ?? (scheduleQ.data?.data as any)?.meta)?.booking_mode === 'service'
|
||
? 'service' : 'slot';
|
||
const serviceMode = bookingMode === 'service' && !isReserve;
|
||
|
||
const sectionsQ = useQuery<ApiResponse<Option[]>>({
|
||
queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'),
|
||
});
|
||
const itemsQ = useQuery<ApiResponse<Option[]>>({
|
||
queryKey: ['service-items', sectionUuid],
|
||
queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`),
|
||
enabled: !!sectionUuid,
|
||
});
|
||
const staffQ = useQuery<ApiResponse<Option[]>>({
|
||
queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff'),
|
||
});
|
||
|
||
// ── timing ─────────────────────────────────────────────────────────────────
|
||
const [date, setDate] = useState(defaultDate);
|
||
const [duration, setDuration] = useState(40);
|
||
const [start, setStart] = useState('15:00');
|
||
const [end, setEnd] = useState(addMinutes('15:00', 40));
|
||
useEffect(() => { setEnd(addMinutes(start, duration)); }, [start, duration]);
|
||
|
||
// ── service-mode: چند سرویس + زمانهای خالیِ پیشنهادی ─────────────────────────
|
||
const [serviceUuids, setServiceUuids] = useState<string[]>([]);
|
||
const [svcNames, setSvcNames] = useState<Record<string, string>>({});
|
||
const [pickedSlot, setPickedSlot] = useState<{ start: number; end: number } | null>(null);
|
||
useEffect(() => { setPickedSlot(null); }, [serviceUuids, date]);
|
||
|
||
const svcSlotsQ = useQuery<ApiResponse<any>>({
|
||
queryKey: ['drawer-service-slots', doctorUuid, date, serviceUuids],
|
||
queryFn: () => api.get(
|
||
`/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}`
|
||
+ serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('')
|
||
),
|
||
enabled: serviceMode && !!date && serviceUuids.length > 0,
|
||
});
|
||
const svcSlots = ((svcSlotsQ.data?.data as any)?.start_times ?? []) as Array<{ start: number; end: number; start_time: string }>;
|
||
const totalMinutes = (svcSlotsQ.data?.data as any)?.total_duration_minutes as number | undefined;
|
||
|
||
// ── deposit / status / notes ───────────────────────────────────────────────
|
||
const [depositRequired, setDepositRequired] = useState(false);
|
||
const [depositToman, setDepositToman] = useState(0);
|
||
const [status, setStatus] = useState('pending');
|
||
const [note, setNote] = useState('');
|
||
|
||
// ── هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت» (فیلد UI تومان،
|
||
// API ریالی). بدون این مقدار وقتی فلگ فعال است backend خطای ۴۲۲ میدهد.
|
||
const pricingQ = useQuery<ApiResponse<{ free_visit_price_rials: number; require_visit_price: boolean }>>({
|
||
queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'),
|
||
});
|
||
const freeVisit = (pricingQ.data as any)?.data?.free_visit_price_rials ?? 0;
|
||
const requireVisit = (pricingQ.data as any)?.data?.require_visit_price ?? false;
|
||
const [visitPriceToman, setVisitPriceToman] = useState(0);
|
||
const [visitPriceTouched, setVisitPriceTouched] = useState(false);
|
||
useEffect(() => {
|
||
if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit));
|
||
}, [freeVisit, visitPriceTouched]);
|
||
|
||
const effectiveName = pickedPatient?.user_name || name.trim();
|
||
const effectiveMobile = pickedPatient?.user_mobile || mobile.trim();
|
||
const effectiveNationalCode = (pickedPatient?.user_national_code || nationalCode).replace(/\D/g, '');
|
||
const timingValid = isReserve
|
||
? true
|
||
: serviceMode
|
||
? (serviceUuids.length > 0 && !!pickedSlot)
|
||
: (!!start && !!end);
|
||
const visitPriceValid = !requireVisit || visitPriceToman > 0;
|
||
const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10
|
||
&& effectiveNationalCode.length === 10 && timingValid && visitPriceValid;
|
||
|
||
const create = useMutation({
|
||
mutationFn: async () => {
|
||
const slotStart = isReserve ? toEpoch(date, '00:00') : serviceMode ? pickedSlot!.start : toEpoch(date, start);
|
||
const slotEnd = isReserve ? toEpoch(date, '00:00') : serviceMode ? pickedSlot!.end : toEpoch(date, end);
|
||
const payload: Record<string, unknown> = {
|
||
doctor_uuid: doctorUuid,
|
||
slot_start: slotStart,
|
||
slot_end: slotEnd,
|
||
patient_name: effectiveName,
|
||
patient_mobile: effectiveMobile,
|
||
patient_national_code: effectiveNationalCode,
|
||
is_reserve: isReserve,
|
||
...(sectionUuid ? { service_section_uuid: sectionUuid } : {}),
|
||
// حالت سرویس: چند سرویس؛ حالت اسلاتی: تک سرویسِ workflow (اختیاری).
|
||
...(serviceMode ? { service_item_uuids: serviceUuids } : itemUuid ? { service_item_uuid: itemUuid } : {}),
|
||
...(staffUuid ? { staff_uuid: staffUuid } : {}),
|
||
...(depositRequired ? { deposit_required: true, deposit_amount_rials: tomanToRial(depositToman) } : {}),
|
||
...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}),
|
||
...(note.trim() ? { note: note.trim() } : {}),
|
||
};
|
||
const res: any = await api.post('/api/v1/my/appointment', payload);
|
||
// POST creates a pending booking; apply the picked status afterwards.
|
||
if (status !== 'pending' && res?.data?.uuid) {
|
||
await api.patch(`/api/v1/appointment/${res.data.uuid}/status`, { status, version: 1 });
|
||
}
|
||
return res;
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey });
|
||
toast.success(isReserve ? 'نوبت رزرو ثبت شد' : 'نوبت با موفقیت ثبت شد');
|
||
onClose();
|
||
},
|
||
onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'),
|
||
});
|
||
|
||
const label = { fontSize: 12.5, color: 'var(--text-3)' } as const;
|
||
const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]);
|
||
|
||
return (
|
||
<Modal open title={isReserve ? 'اضافه کردن نوبت رزرو' : 'اضافه کردن نوبت جدید'} onClose={onClose}>
|
||
<div>
|
||
<div style={{ fontSize: 13.5, fontWeight: 700, marginBottom: 10 }}>اطلاعات مراجعه کننده:</div>
|
||
<label style={label}>انتخاب مراجعه کننده</label>
|
||
<div className="field" style={{ margin: '6px 0 8px' }}>
|
||
<input value={pickedPatient ? `${pickedPatient.user_name ?? ''} — ${pickedPatient.user_mobile ?? ''}` : patientSearch}
|
||
onChange={e => { setPickedPatient(null); setPatientSearch(e.target.value); }}
|
||
placeholder="جستجوی نام، شماره تماس، شماره پرونده..." />
|
||
</div>
|
||
{!pickedPatient && patients.length > 0 && (
|
||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', marginBottom: 10, overflow: 'hidden' }}>
|
||
{patients.map(p => (
|
||
<button key={p.uuid} onClick={() => setPickedPatient(p)} style={{
|
||
display: 'block', width: '100%', padding: '8px 10px', fontSize: 13, textAlign: 'right',
|
||
background: 'transparent', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||
}}>
|
||
{p.user_name} <span style={{ color: 'var(--text-3)', direction: 'ltr' }}>{p.user_mobile}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{pickedPatient === null && (
|
||
<>
|
||
<div style={{ margin: '4px 0 10px' }}>
|
||
<span className="badge" style={{ color: 'var(--primary)', border: '1px solid var(--primary)', borderRadius: 'var(--r-sm)', padding: '5px 10px', fontSize: 12, display: 'inline-flex', gap: 5, alignItems: 'center' }}>
|
||
<PlusIcon style={{ width: 13 }} /> مراجعه کننده جدید
|
||
</span>
|
||
</div>
|
||
<label style={label}>نام و نام خانوادگی مراجعه کننده</label>
|
||
<div className="field" style={{ margin: '6px 0 10px' }}>
|
||
<input value={name} onChange={e => setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده را وارد نمایید" />
|
||
</div>
|
||
<label style={label}>شماره تماس</label>
|
||
<div className="field" style={{ margin: '6px 0 12px' }}>
|
||
<input value={mobile} onChange={e => setMobile(sanitizeMobileInput(e.target.value))} placeholder="شماره تماس مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={11} />
|
||
</div>
|
||
<label style={label}>کد ملی</label>
|
||
<div className="field" style={{ margin: '6px 0 12px' }}>
|
||
<input value={nationalCode} onChange={e => setNationalCode(toEnglishDigits(e.target.value).replace(/\D/g, '').slice(0, 10))}
|
||
placeholder="کد ملی مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={10} />
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>مشخصات سرویس:</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 10 }}>
|
||
<div>
|
||
<label style={label}>بخش</label>
|
||
<div style={{ marginTop: 6 }}>
|
||
<SearchableSelect
|
||
options={(sectionsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
||
value={sectionUuid || null}
|
||
onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }}
|
||
placeholder="انتخاب بخش"
|
||
isLoading={sectionsQ.isLoading}
|
||
isClearable
|
||
height={38}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style={label}>سرویس{serviceMode ? ' (یک یا چند)' : ''}</label>
|
||
<div style={{ marginTop: 6 }}>
|
||
<SearchableSelect
|
||
options={(itemsQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.name ?? '' }))}
|
||
value={serviceMode ? null : (itemUuid || null)}
|
||
isDisabled={!sectionUuid}
|
||
isLoading={itemsQ.isLoading}
|
||
placeholder={serviceMode ? 'افزودن سرویس' : 'انتخاب سرویس'}
|
||
onChange={v => {
|
||
const uuid = v ? String(v) : '';
|
||
if (!uuid) { if (!serviceMode) setItemUuid(''); return; }
|
||
if (serviceMode) {
|
||
const name = (itemsQ.data?.data ?? []).find(o => o.uuid === uuid)?.name ?? '';
|
||
setServiceUuids(prev => prev.includes(uuid) ? prev : [...prev, uuid]);
|
||
setSvcNames(prev => ({ ...prev, [uuid]: name }));
|
||
} else {
|
||
setItemUuid(uuid);
|
||
}
|
||
}}
|
||
height={38}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{serviceMode && serviceUuids.length > 0 && (
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 10 }}>
|
||
{serviceUuids.map(uuid => (
|
||
<span key={uuid} className="badge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 12, border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', padding: '4px 8px' }}>
|
||
{svcNames[uuid] ?? uuid}
|
||
<button type="button" aria-label="حذف سرویس" onClick={() => setServiceUuids(prev => prev.filter(u => u !== uuid))}
|
||
style={{ border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-3)', fontSize: 14, lineHeight: 1 }}>×</button>
|
||
</span>
|
||
))}
|
||
{totalMinutes != null && <span style={{ fontSize: 12, color: 'var(--text-3)', alignSelf: 'center' }}>مدت کل: {totalMinutes} دقیقه</span>}
|
||
</div>
|
||
)}
|
||
<label style={label}>انتخاب پرسنل</label>
|
||
<div style={{ margin: '6px 0 12px' }}>
|
||
<SearchableSelect
|
||
options={(staffQ.data?.data ?? []).map(o => ({ value: o.uuid, label: o.full_name ?? '' }))}
|
||
value={staffUuid || null}
|
||
onChange={v => setStaffUuid(v ? String(v) : '')}
|
||
placeholder="انتخاب..."
|
||
isLoading={staffQ.isLoading}
|
||
isClearable
|
||
height={38}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>زمان نوبت:</div>
|
||
<label style={label}>انتخاب تاریخ</label>
|
||
<div style={{ margin: '6px 0 10px' }}><PersianDateInput value={date} onChange={setDate} /></div>
|
||
|
||
{serviceMode ? (
|
||
<div style={{ marginBottom: 12 }}>
|
||
<label style={label}>زمانهای خالی پیشنهادی</label>
|
||
{serviceUuids.length === 0 ? (
|
||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 6 }}>ابتدا سرویس را انتخاب کنید.</div>
|
||
) : svcSlotsQ.isLoading ? (
|
||
<div style={{ fontSize: 12.5, color: 'var(--text-3)', marginTop: 6 }}>در حال محاسبه...</div>
|
||
) : svcSlots.length === 0 ? (
|
||
<div style={{ fontSize: 12.5, color: 'var(--danger)', marginTop: 6 }}>برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.</div>
|
||
) : (
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginTop: 6 }}>
|
||
{svcSlots.map(s => {
|
||
const active = pickedSlot?.start === s.start;
|
||
return (
|
||
<button key={s.start} type="button" dir="ltr" onClick={() => setPickedSlot({ start: s.start, end: s.end })}
|
||
style={{ fontSize: 13, padding: '6px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontFamily: 'inherit',
|
||
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
|
||
background: active ? 'var(--primary)' : 'var(--surface)', color: active ? '#fff' : 'var(--text)' }}>
|
||
{s.start_time}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<>
|
||
<label style={label}>زمان پیش فرض (دقیقه)</label>
|
||
<div className="field" style={{ margin: '6px 0 10px' }}>
|
||
<input aria-label="زمان پیش فرض" type="number" min={5} value={duration} onChange={e => setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" />
|
||
</div>
|
||
{!isReserve && (
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 12 }}>
|
||
<div>
|
||
<label style={label}>ساعت شروع</label>
|
||
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت شروع" type="time" value={start} onChange={e => setStart(e.target.value)} dir="ltr" /></div>
|
||
</div>
|
||
<div>
|
||
<label style={label}>ساعت پایان</label>
|
||
<div className="field" style={{ marginTop: 6 }}><input aria-label="ساعت پایان" type="time" value={end} onChange={e => setEnd(e.target.value)} dir="ltr" /></div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{!isReserve && (
|
||
<>
|
||
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>بیعانه:</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={depositRequired} onChange={e => setDepositRequired(e.target.checked)} />
|
||
بیعانه مورد نیاز است.
|
||
</label>
|
||
</div>
|
||
{depositRequired && (
|
||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 10, marginBottom: 12 }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={label}>مبلغ بیعانه (تومان)</label>
|
||
<div style={{ marginTop: 6 }}>
|
||
<PriceInput value={depositToman} onChange={setDepositToman} />
|
||
</div>
|
||
</div>
|
||
<WalletChargeLink mobile={effectiveMobile} />
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
<div style={{ fontSize: 13.5, fontWeight: 700, margin: '6px 0 10px' }}>هزینه ویزیت:</div>
|
||
<label style={label}>
|
||
هزینه ویزیت (تومان){requireVisit && <span style={{ color: 'var(--danger)' }}> *</span>}
|
||
</label>
|
||
<div style={{ margin: '6px 0 4px' }}>
|
||
<PriceInput value={visitPriceToman} onChange={(v) => { setVisitPriceToman(v); setVisitPriceTouched(true); }} />
|
||
</div>
|
||
{requireVisit && visitPriceToman <= 0 && (
|
||
<div style={{ fontSize: 12, color: 'var(--danger)', marginBottom: 12 }}>هزینه ویزیت الزامی است</div>
|
||
)}
|
||
|
||
<label style={label}>انتخاب وضعیت</label>
|
||
<div style={{ margin: '6px 0 12px' }}>
|
||
<SearchableSelect
|
||
options={[{ value: 'pending', label: 'ثبت شده' }, { value: 'confirmed', label: 'قطعی شده' }]}
|
||
value={status || null}
|
||
onChange={v => setStatus(v ? String(v) : '')}
|
||
placeholder="انتخاب وضعیت"
|
||
height={38}
|
||
/>
|
||
</div>
|
||
|
||
<div className="field" style={{ height: 'auto', marginBottom: 16 }}>
|
||
<textarea value={note} onChange={e => setNote(e.target.value)} rows={3} placeholder="توضیحات..."
|
||
style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} />
|
||
</div>
|
||
|
||
<button className="btn primary" style={{ width: '100%' }} disabled={!valid || create.isPending} onClick={() => create.mutate()}>
|
||
ثبت نوبت
|
||
</button>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|