Files
clinicpro/assets/admin/components/NewAppointmentDrawer.tsx
T
hamedandClaude Fable 5 36d7fe0303 fix(admin): send visit_price_rials from appointment create modal & drawer
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>
2026-07-17 11:02:23 +03:30

392 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}