feat: add service-based booking mode to appointment scheduling
- Introduced a new booking mode in WeeklySchedule to support service-based appointments. - Updated SlotCalculatorService to calculate available start times based on selected service durations and buffer times. - Enhanced AppointmentController to handle service items during booking, calculating slot_end on the server side. - Implemented validation to ensure at least one bookable service exists for doctors in service mode. - Added new API endpoint to retrieve available appointment slots based on selected services. - Updated MyAppointmentsController to accept service items during appointment creation. - Modified ServiceItem entity to include a bookable flag, allowing services to be marked for scheduling. - Created migration to add bookable column to service_items table. - Added tests for service-based slot calculations and validation logic.
This commit is contained in:
@@ -54,6 +54,17 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
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'),
|
||||
});
|
||||
@@ -73,6 +84,23 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
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 [depositRials, setDepositRials] = useState(0);
|
||||
@@ -82,21 +110,29 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
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 valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10
|
||||
&& effectiveNationalCode.length === 10 && (isReserve || (!!start && !!end));
|
||||
&& effectiveNationalCode.length === 10 && timingValid;
|
||||
|
||||
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: isReserve ? toEpoch(date, '00:00') : toEpoch(date, start),
|
||||
slot_end: isReserve ? toEpoch(date, '00:00') : toEpoch(date, end),
|
||||
slot_start: slotStart,
|
||||
slot_end: slotEnd,
|
||||
patient_name: effectiveName,
|
||||
patient_mobile: effectiveMobile,
|
||||
patient_national_code: effectiveNationalCode,
|
||||
is_reserve: isReserve,
|
||||
...(sectionUuid ? { service_section_uuid: sectionUuid } : {}),
|
||||
...(itemUuid ? { service_item_uuid: itemUuid } : {}),
|
||||
// حالت سرویس: چند سرویس؛ حالت اسلاتی: تک سرویسِ workflow (اختیاری).
|
||||
...(serviceMode ? { service_item_uuids: serviceUuids } : itemUuid ? { service_item_uuid: itemUuid } : {}),
|
||||
...(staffUuid ? { staff_uuid: staffUuid } : {}),
|
||||
...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}),
|
||||
...(note.trim() ? { note: note.trim() } : {}),
|
||||
@@ -175,13 +211,41 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={label}>سرویس</label>
|
||||
<select aria-label="سرویس" style={{ ...sel, marginTop: 6 }} value={itemUuid} onChange={e => setItemUuid(e.target.value)} disabled={!sectionUuid}>
|
||||
<option value="">انتخاب سرویس</option>
|
||||
<label style={label}>سرویس{serviceMode ? ' (یک یا چند)' : ''}</label>
|
||||
<select
|
||||
aria-label="سرویس"
|
||||
style={{ ...sel, marginTop: 6 }}
|
||||
value={serviceMode ? '' : itemUuid}
|
||||
disabled={!sectionUuid}
|
||||
onChange={e => {
|
||||
const uuid = e.target.value;
|
||||
if (!uuid) 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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">{serviceMode ? 'افزودن سرویس' : 'انتخاب سرویس'}</option>
|
||||
{(itemsQ.data?.data ?? []).map(o => <option key={o.uuid} value={o.uuid}>{o.name}</option>)}
|
||||
</select>
|
||||
</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>
|
||||
<select aria-label="پرسنل" style={{ ...sel, margin: '6px 0 12px' }} value={staffUuid} onChange={e => setStaffUuid(e.target.value)}>
|
||||
<option value="">انتخاب...</option>
|
||||
@@ -191,21 +255,51 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{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 && (
|
||||
|
||||
@@ -30,6 +30,7 @@ const itemSchema = z.object({
|
||||
insurance_covered: z.boolean().optional(),
|
||||
insurance_price_rials: z.coerce.number().min(0).optional(),
|
||||
duration_minutes: z.coerce.number().min(0).optional(),
|
||||
bookable: z.boolean().optional(),
|
||||
});
|
||||
type SectionForm = z.infer<typeof sectionSchema>;
|
||||
type ItemForm = z.infer<typeof itemSchema>;
|
||||
@@ -207,12 +208,13 @@ function ClinicServicesPageInner() {
|
||||
insurance_covered: item.insurance_covered ?? false,
|
||||
insurance_price_rials: rialToToman(item.insurance_price_rials ?? 0),
|
||||
duration_minutes: item.duration_minutes ?? undefined,
|
||||
bookable: item.bookable ?? false,
|
||||
});
|
||||
setItemModal(item);
|
||||
};
|
||||
|
||||
const openCreateItem = () => {
|
||||
itemForm.reset({ name: '', price_rials: 0, staff_uuids: [], insurance_covered: false, insurance_price_rials: 0, duration_minutes: undefined });
|
||||
itemForm.reset({ name: '', price_rials: 0, staff_uuids: [], insurance_covered: false, insurance_price_rials: 0, duration_minutes: undefined, bookable: false });
|
||||
setItemModal('create');
|
||||
};
|
||||
|
||||
@@ -376,9 +378,12 @@ function ClinicServicesPageInner() {
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||
<ClockIcon style={{ width: 14, color: 'var(--text-3)' }} /> زمان متوسط:
|
||||
</span>
|
||||
{item.duration_minutes
|
||||
? <span className="badge blue" style={{ fontSize: 11 }}>{formatNumber(Number(item.duration_minutes))} دقیقه</span>
|
||||
: <span style={{ fontSize: 12, color: 'var(--text-3)' }}>—</span>}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
{item.bookable && <span className="badge green" style={{ fontSize: 11 }}>در نوبتدهی</span>}
|
||||
{item.duration_minutes
|
||||
? <span className="badge blue" style={{ fontSize: 11 }}>{formatNumber(Number(item.duration_minutes))} دقیقه</span>
|
||||
: <span style={{ fontSize: 12, color: 'var(--text-3)' }}>—</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{item.insurance_covered && item.insurance_price_rials != null && (
|
||||
@@ -517,13 +522,24 @@ function ClinicServicesPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, alignItems: 'end' }}>
|
||||
<div>
|
||||
<label className="field-label">زمان متوسط (دقیقه)</label>
|
||||
<div className="field">
|
||||
<input type="number" min={0} {...itemForm.register('duration_minutes')} placeholder="مثلاً: ۵۰" />
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', padding: '9px 0' }}>
|
||||
<span className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={itemForm.watch('bookable') ?? false}
|
||||
onChange={(e) => itemForm.setValue('bookable', e.target.checked)}
|
||||
/>
|
||||
<span className="switch-track"><span className="switch-thumb" /></span>
|
||||
</span>
|
||||
<span style={{ fontSize: 13 }}>نمایش در نوبتدهی</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -101,11 +101,15 @@ interface BookingMeta {
|
||||
online_booking_enabled: boolean;
|
||||
booking_window_value: number;
|
||||
booking_window_unit: 'week' | 'month';
|
||||
booking_mode: 'slot' | 'service';
|
||||
buffer_minutes: number;
|
||||
}
|
||||
const DEFAULT_BOOKING_META: BookingMeta = {
|
||||
online_booking_enabled: true,
|
||||
booking_window_value: 1,
|
||||
booking_window_unit: 'month',
|
||||
booking_mode: 'slot',
|
||||
buffer_minutes: 0,
|
||||
};
|
||||
interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; meta?: BookingMeta; }
|
||||
|
||||
@@ -1384,6 +1388,53 @@ export function WeeklyScheduleTab({ doctorUuid, addresses, readOnly = false }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─ روش نوبتدهی */}
|
||||
<div className="mb-3 rounded-xl border border-slate-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 dark:bg-gray-800/50">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">روش نوبتدهی</span>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{([['slot', 'اسلاتی (مدت ثابت)'], ['service', 'بر اساس سرویس']] as const).map(([val, lbl]) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => setMeta(m => ({ ...m, booking_mode: val }))}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg border transition-colors ${
|
||||
meta.booking_mode === val
|
||||
? 'bg-[var(--primary)] text-white border-[var(--primary)]'
|
||||
: 'bg-white dark:bg-gray-900 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{meta.booking_mode === 'service' ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">فاصله بین نوبتها</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={meta.buffer_minutes}
|
||||
onChange={(e) => setMeta(m => ({ ...m, buffer_minutes: Math.max(0, Number(e.target.value) || 0) }))}
|
||||
className="w-16 text-center text-sm rounded-lg border border-slate-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1.5 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">دقیقه</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 leading-relaxed">
|
||||
مدت هر نوبت از «مدت سرویس» انتخابشده تعیین میشود. لازم است حداقل یک سرویس با «نمایش در نوبتدهی» در بخش <span className="font-medium">سرویسها</span> تعریف کنید، وگرنه ذخیره نمیشود.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 leading-relaxed">
|
||||
مدت هر نوبت از «زمان هر نوبت» در شیفتهای زیر تعیین میشود.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─ نوبتدهی آنلاین */}
|
||||
<div className="mb-3 rounded-xl border border-slate-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* header + toggle */}
|
||||
|
||||
@@ -463,6 +463,7 @@ export interface ServiceItem {
|
||||
insurance_covered?: boolean;
|
||||
insurance_price_rials?: number | null;
|
||||
duration_minutes?: number | null;
|
||||
bookable?: boolean;
|
||||
}
|
||||
|
||||
export interface SmsWalletBalance {
|
||||
|
||||
Reference in New Issue
Block a user