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:
hamed
2026-07-15 23:15:45 +03:30
parent 6904361e32
commit 5937f7e176
16 changed files with 817 additions and 26 deletions
+21 -5
View File
@@ -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>
+51
View File
@@ -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 */}