Files
clinicpro/assets/admin/components/appointments/ServiceSlotPicker.tsx
T
hamed 4f69bc9044 feat: Implement resource booking functionality
- Add service timeline builder for appointments to manage available slots.
- Create a hook to fetch resource booking services with effective durations.
- Develop ResourceBookingSlotController to handle API requests for resource booking slots.
- Implement ResourceBookingSlotService to calculate available time slots based on resource occupancy and service durations.
- Add tests for resource appointment creation and booking slot functionality to ensure correct behavior and edge cases.
2026-08-03 14:34:23 +03:30

253 lines
14 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, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../lib/api';
import type { ApiResponse } from '../../lib/api';
import type { BookingService } from '../../hooks/useDoctorBookingServices';
import { useClinicContext } from '../../hooks/useClinicContext';
import SearchableSelect from '../ui/SearchableSelect';
import DigitInput from '../ui/DigitInput';
interface ServiceSlot { start: number; end: number; start_time: string }
export interface PickedService { uuid: string; name: string; section: string; duration: number }
export interface ServicePick { serviceUuids: string[]; durations: Record<string, number>; slot: ServiceSlot | null }
/**
* انتخاب سرویس بر اساس بخش (بخش → سرویس، انباشته از چند بخش) + زمان‌های خالیِ پیشنهادی
* برای نوبت‌دهی سرویسی. مدتِ هر سرویس در پنل قابل ویرایش است (فقط برای همین نوبت؛ پیش‌فرضِ
* سرویس تغییر نمی‌کند). مدت کل = مجموع مدت‌ها؛ زمان‌ها از `appointment-service-slots`
* با اعمال همان override محاسبه می‌شوند. انتخاب را از طریق onSelect بالا می‌فرستد.
*/
export default function ServiceSlotPicker({
doctorUuid, resourceUuid, date, services, onSelect, editableDuration = true, clinicUuidOverride,
excludeAppointmentUuid, initialSelection,
}: {
doctorUuid: string;
/**
* نوبت‌دهی روی یک منبع: زمان‌ها از تقویم خودِ منبع می‌آیند، نه از برنامهٔ پزشک.
*
* فرمِ انتخاب سرویس در هر دو حالت یکی است (بخش → سرویس → مدت → زمان)، پس فقط
* منبعِ زمان عوض می‌شود نه کامپوننت — دو نسخه یعنی دو رفتار.
*/
resourceUuid?: string;
date: string;
services: BookingService[];
onSelect: (v: ServicePick) => void;
editableDuration?: boolean;
/** undefined = context محیط جاری؛ مقدار صریح (شامل null) = محل انتخاب‌شده خارج از context */
clinicUuidOverride?: string | null;
/**
* ویرایش نوبت: بازهٔ خودِ همین نوبت اشغال حساب نشود، وگرنه زمان فعلی‌اش در فهرست
* نمی‌آید و کاربر نمی‌تواند «همان ساعت، سرویس متفاوت» را ثبت کند.
*/
excludeAppointmentUuid?: string;
/** سرویس‌های از قبل انتخاب‌شده (ویرایش نوبت موجود). فقط یک بار هیدریت می‌شود. */
initialSelection?: PickedService[];
}) {
const contextClinicUuid = useClinicContext();
const clinicUuid = clinicUuidOverride === undefined ? contextClinicUuid : clinicUuidOverride;
const [sectionUuid, setSectionUuid] = useState('');
const [selected, setSelected] = useState<PickedService[]>(initialSelection ?? []);
const [pickedSlot, setPickedSlot] = useState<ServiceSlot | null>(null);
// بخش‌های یکتا از روی سرویس‌های bookable (بدون endpoint اضافه — همه یکجا آمده‌اند).
const sections = useMemo(() => {
const map = new Map<string, { uuid: string; name: string }>();
services.forEach(s => { if (s.service_section) map.set(s.service_section.uuid, s.service_section); });
return [...map.values()];
}, [services]);
const sectionServices = useMemo(
() => services.filter(s => s.service_section?.uuid === sectionUuid),
[services, sectionUuid],
);
// تعویض پزشک ⇒ لیست سرویس‌ها عوض می‌شود؛ انتخاب‌ها ریست شوند. اجرای نخست معاف است،
// وگرنه initialSelection (ویرایش نوبت موجود) همان لحظه پاک می‌شد.
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) { mounted.current = true; return; }
setSelected([]); setSectionUuid('');
}, [doctorUuid, resourceUuid]);
useEffect(() => { setPickedSlot(null); }, [selected, date, doctorUuid, resourceUuid]);
const serviceUuids = useMemo(() => selected.map(s => s.uuid), [selected]);
const durations = useMemo(
() => Object.fromEntries(selected.map(s => [s.uuid, s.duration])) as Record<string, number>,
[selected],
);
useEffect(() => { onSelect({ serviceUuids, durations, slot: pickedSlot }); }, [serviceUuids, durations, pickedSlot]);
const durationsQs = selected.map(s => `&durations[${encodeURIComponent(s.uuid)}]=${s.duration}`).join('');
const servicesQs = serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('');
const slotsQ = useQuery<ApiResponse<any>>({
queryKey: ['service-slots-picker', resourceUuid ?? doctorUuid, date, serviceUuids, durations, clinicUuid, excludeAppointmentUuid],
queryFn: () => api.get(
resourceUuid
? `/api/v1/resource/${resourceUuid}/service-slots?date=${date}` + servicesQs + durationsQs
: `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1`
+ servicesQs
+ durationsQs
+ (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '')
+ (excludeAppointmentUuid ? `&exclude_appointment_uuid=${encodeURIComponent(excludeAppointmentUuid)}` : ''),
),
enabled: (!!resourceUuid || !!doctorUuid) && !!date && serviceUuids.length > 0,
});
const startTimes: ServiceSlot[] = (slotsQ.data?.data as any)?.start_times ?? [];
const totalMinutes = (slotsQ.data?.data as any)?.total_duration_minutes as number | undefined;
const label = { fontSize: 12.5, color: 'var(--text-3)', display: 'block' } as const;
const toggle = (s: BookingService) =>
setSelected(prev => prev.some(p => p.uuid === s.uuid)
? prev.filter(p => p.uuid !== s.uuid)
: [...prev, { uuid: s.uuid, name: s.name, section: s.service_section.name, duration: s.duration_minutes ?? 0 }]);
const remove = (uuid: string) => setSelected(prev => prev.filter(p => p.uuid !== uuid));
const setDuration = (uuid: string, minutes: number) =>
setSelected(prev => prev.map(p => p.uuid === uuid ? { ...p, duration: minutes } : p));
if (services.length === 0) {
return (
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
{resourceUuid
? 'برای این منبع سرویسی تعریف نشده است — از تب «سرویس‌ها»ی همین منبع اضافه کنید.'
: 'سرویسی با «نمایش در نوبت‌دهی» برای این پزشک تعریف نشده است.'}
</div>
);
}
return (
<div>
{/* انتخاب بخش */}
<label style={label}>بخش</label>
<div style={{ margin: '6px 0 10px', maxWidth: 400 }}>
<SearchableSelect
inputId="service-mode-section-select"
options={sections.map(s => ({ value: s.uuid, label: s.name }))}
value={sectionUuid || null}
onChange={v => setSectionUuid(v ? String(v) : '')}
placeholder="ابتدا بخش را انتخاب کنید"
isClearable
height={44}
/>
</div>
{/* سرویس‌های بخشِ انتخاب‌شده — چند انتخابی */}
{sectionUuid && (
<>
<label style={label}>سرویس‌های این بخش (یک یا چند)</label>
{sectionServices.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0 10px' }}>سرویسی در این بخش تعریف نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, margin: '6px 0 12px' }}>
{sectionServices.map(s => {
const active = selected.some(p => p.uuid === s.uuid);
return (
<button key={s.uuid} type="button" onClick={() => toggle(s)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
padding: '9px 12px', borderRadius: 'var(--r-sm)', cursor: 'pointer', textAlign: 'right',
fontFamily: 'inherit', fontSize: 13,
border: active ? '1px solid var(--primary)' : '1px solid var(--border)',
background: active ? 'var(--primary-soft)' : 'var(--surface)',
}}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<span style={{
width: 16, height: 16, borderRadius: 4, display: 'grid', placeItems: 'center', flexShrink: 0,
border: active ? '1px solid var(--primary)' : '1px solid var(--border-2)',
background: active ? 'var(--primary)' : 'transparent',
}}>
{active && <span style={{ width: 8, height: 8, background: 'var(--surface)', borderRadius: 2 }} />}
</span>
{s.name}
</span>
{s.duration_minutes ? <span style={{ color: 'var(--text-3)', fontSize: 12 }}>{s.duration_minutes} دقیقه</span> : null}
</button>
);
})}
</div>
)}
</>
)}
{/* لیستِ انباشتهٔ سرویس‌های انتخاب‌شده (از هر بخش) — «بخش → سرویس» + مدت قابل‌ویرایش + حذف */}
{selected.length > 0 && (
<div style={{ margin: '4px 0 12px' }}>
<label style={label}>سرویس‌های انتخاب‌شده ({selected.length})</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 6 }}>
{selected.map(s => (
<div key={s.uuid} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 10px', borderRadius: 'var(--r-sm)', fontSize: 13,
background: 'var(--primary-soft)', border: '1px solid var(--primary)',
}}>
<span style={{
flex: 1, minWidth: 0, color: 'var(--primary-700)', fontWeight: 600,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
<span style={{ color: 'var(--text-3)', fontWeight: 400 }}>{s.section}</span>
{' ← '}{s.name}
</span>
{editableDuration ? (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0,
height: 32, padding: '0 8px', borderRadius: 'var(--r-sm)',
background: 'var(--surface)', border: '1px solid var(--border-2)',
}}>
<DigitInput
aria-label={`مدت ${s.name}`}
value={String(s.duration || '')}
onChange={v => setDuration(s.uuid, Number(v) || 0)}
maxDigits={3}
style={{ width: 34, border: 'none', outline: 'none', background: 'transparent', textAlign: 'center', fontFamily: 'inherit', fontSize: 13, color: 'var(--text)' }}
/>
<span style={{ color: 'var(--text-3)', fontSize: 11.5 }}>دقیقه</span>
</span>
) : (
<span style={{ flexShrink: 0, color: 'var(--text-3)', fontSize: 12 }}>{s.duration} دقیقه</span>
)}
<button type="button" aria-label={`حذف ${s.name}`} onClick={() => remove(s.uuid)}
style={{
display: 'grid', placeItems: 'center', width: 18, height: 18, borderRadius: 999, flexShrink: 0,
border: 'none', cursor: 'pointer', background: 'var(--primary)', color: 'var(--on-primary)',
fontSize: 13, lineHeight: 1, fontFamily: 'inherit',
}}>×</button>
</div>
))}
</div>
</div>
)}
{/* زمان‌های خالی پیشنهادی */}
{selected.length > 0 && (
<>
<label style={label}>زمان‌های خالی پیشنهادی{totalMinutes != null ? ` (مدت کل: ${totalMinutes} دقیقه)` : ''}</label>
{slotsQ.isLoading ? (
<div style={{ fontSize: 12.5, color: 'var(--text-3)', margin: '6px 0' }}>در حال محاسبه...</div>
) : startTimes.length === 0 ? (
<div style={{ fontSize: 12.5, color: 'var(--danger)', margin: '6px 0' }}>
برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, margin: '6px 0 4px' }}>
{startTimes.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, start_time: s.start_time })}
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 ? 'var(--on-primary)' : 'var(--text)',
}}>
{s.start_time}
</button>
);
})}
</div>
)}
</>
)}
</div>
);
}