feat: enhance appointment scheduling with session management and availability checks
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
@@ -28,16 +29,39 @@ interface Props {
|
||||
|
||||
export default function AppointmentStatusDropdown({ uuid, currentStatus, version, queryKey }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handler(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
const target = e.target as Node;
|
||||
if (!btnRef.current?.contains(target) && !menuRef.current?.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, []);
|
||||
}, [open]);
|
||||
|
||||
// Reposition on scroll/resize while open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function reposition() {
|
||||
if (btnRef.current) {
|
||||
const rect = btnRef.current.getBoundingClientRect();
|
||||
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
|
||||
}
|
||||
}
|
||||
window.addEventListener('scroll', reposition, true);
|
||||
window.addEventListener('resize', reposition);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', reposition, true);
|
||||
window.removeEventListener('resize', reposition);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (newStatus: string) =>
|
||||
@@ -52,10 +76,19 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
|
||||
const meta = STATUS_META[currentStatus] ?? { label: currentStatus, color: '#9ca3af' };
|
||||
const nextStatuses = TRANSITIONS[currentStatus] ?? [];
|
||||
|
||||
function handleToggle() {
|
||||
if (!open && btnRef.current) {
|
||||
const rect = btnRef.current.getBoundingClientRect();
|
||||
setMenuPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
|
||||
}
|
||||
setOpen(o => !o);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
ref={btnRef}
|
||||
onClick={handleToggle}
|
||||
disabled={nextStatuses.length === 0}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
@@ -74,16 +107,19 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && nextStatuses.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', right: 0, marginTop: 4, zIndex: 100,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
|
||||
minWidth: 160, overflow: 'hidden',
|
||||
}}>
|
||||
{open && menuPos && nextStatuses.length > 0 && ReactDOM.createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: 'fixed', top: menuPos.top, right: menuPos.right,
|
||||
zIndex: 9000,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
|
||||
minWidth: 160, overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{nextStatuses.map(s => {
|
||||
const sm = STATUS_META[s] ?? { label: s, color: '#9ca3af' };
|
||||
const isCurrent = s === currentStatus;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
@@ -93,7 +129,7 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
width: '100%', padding: '8px 12px', fontSize: 13,
|
||||
background: 'transparent', border: 'none', cursor: 'pointer',
|
||||
color: sm.color, fontWeight: isCurrent ? 700 : 400,
|
||||
color: sm.color, fontWeight: 400,
|
||||
textAlign: 'right',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--surface-2)')}
|
||||
@@ -101,14 +137,14 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
|
||||
>
|
||||
<span style={{
|
||||
width: 10, height: 10, borderRadius: '50%', flexShrink: 0,
|
||||
background: isCurrent ? sm.color : 'transparent',
|
||||
border: `2px solid ${sm.color}`,
|
||||
}} />
|
||||
{sm.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -259,11 +259,71 @@ interface SlotItem {
|
||||
end_time: string;
|
||||
is_available: boolean;
|
||||
appointment: Appointment | null;
|
||||
cancelled_appointment: Appointment | null;
|
||||
}
|
||||
|
||||
interface SessionGroup {
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
slots: SlotItem[];
|
||||
}
|
||||
|
||||
function CancelledBadge({ appt }: { appt: Appointment }) {
|
||||
const sm = statusMeta(appt.status);
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
|
||||
borderRadius: 'var(--r-sm)', background: '#f9fafb',
|
||||
border: '1px dashed #d1d5db', fontSize: 12, color: '#6b7280',
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
<span style={{ fontSize: 14, lineHeight: 1 }}>⊘</span>
|
||||
<span style={{
|
||||
padding: '1px 6px', borderRadius: 99, fontSize: 11, fontWeight: 700,
|
||||
background: `${sm.color}15`, color: sm.color, whiteSpace: 'nowrap',
|
||||
}}>{sm.label}</span>
|
||||
<span style={{ fontWeight: 600, color: '#374151' }}>{appt.patient_name || '—'}</span>
|
||||
<span style={{ direction: 'ltr', color: '#9ca3af' }}>{appt.patient_mobile}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotCard({ slot, queryKey, onBook }: { slot: SlotItem; queryKey: unknown[]; onBook: (slot: SlotItem) => void }) {
|
||||
if (slot.is_available) {
|
||||
if (!slot.is_available && slot.appointment) {
|
||||
const a = slot.appointment;
|
||||
const sm = statusMeta(a.status);
|
||||
return (
|
||||
<div style={{
|
||||
border: `1.5px solid ${sm.color}40`, borderRadius: 'var(--r)',
|
||||
background: sm.bg, overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '8px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
borderBottom: `1px solid ${sm.color}20`,
|
||||
}}>
|
||||
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', fontWeight: 600 }}>
|
||||
{slot.start_time} — {slot.end_time}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
||||
<UserCircleIcon style={{ width: 16, height: 16, color: 'var(--text-3)' }} />
|
||||
<span style={{ fontWeight: 600 }}>{a.patient_name || '—'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
|
||||
<PhoneIcon style={{ width: 14, height: 14 }} />
|
||||
<span style={{ direction: 'ltr' }}>{a.patient_mobile}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Available slot — possibly with cancelled history
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{slot.cancelled_appointment && <CancelledBadge appt={slot.cancelled_appointment} />}
|
||||
<div
|
||||
onClick={() => onBook(slot)}
|
||||
style={{
|
||||
@@ -277,46 +337,39 @@ function SlotCard({ slot, queryKey, onBook }: { slot: SlotItem; queryKey: unknow
|
||||
>
|
||||
<span style={{ fontSize: 18 }}>⊕</span> نوبت جدید
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const a = slot.appointment!;
|
||||
const sm = statusMeta(a.status);
|
||||
function SessionDivider({ startTime, endTime, count }: { startTime: string; endTime: string; count: number }) {
|
||||
const hour = parseInt(startTime.split(':')[0], 10);
|
||||
const isAm = hour < 12;
|
||||
const icon = isAm ? '🌅' : '🌆';
|
||||
const label = isAm ? 'صبح' : 'عصر';
|
||||
const color = isAm ? '#f59e0b' : '#6366f1';
|
||||
return (
|
||||
<div style={{
|
||||
border: `1.5px solid ${sm.color}40`, borderRadius: 'var(--r)',
|
||||
background: sm.bg, overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '8px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
borderBottom: `1px solid ${sm.color}20`,
|
||||
}}>
|
||||
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)', fontWeight: 600 }}>
|
||||
{slot.start_time} — {slot.end_time}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
||||
<UserCircleIcon style={{ width: 16, height: 16, color: 'var(--text-3)' }} />
|
||||
<span style={{ fontWeight: 600 }}>{a.patient_name || '—'}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)' }}>
|
||||
<PhoneIcon style={{ width: 14, height: 14 }} />
|
||||
<span style={{ direction: 'ltr' }}>{a.patient_mobile}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 2px' }}>
|
||||
<span style={{ fontSize: 15 }}>{icon}</span>
|
||||
<span style={{ fontWeight: 700, fontSize: 13, color }}>{label}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-3)', fontWeight: 500 }}>
|
||||
{startTime} — {endTime}
|
||||
</span>
|
||||
<span style={{
|
||||
background: `${color}18`, borderRadius: 99, padding: '1px 8px',
|
||||
fontSize: 11, color, fontWeight: 600,
|
||||
}}>{count} نوبت</span>
|
||||
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleView({
|
||||
slots, loading, queryKey, onBook,
|
||||
sessions, loading, queryKey, onBook,
|
||||
}: {
|
||||
slots: SlotItem[]; loading: boolean; queryKey: unknown[]; onBook: (s: SlotItem) => void;
|
||||
sessions: SessionGroup[]; loading: boolean; queryKey: unknown[]; onBook: (s: SlotItem) => void;
|
||||
}) {
|
||||
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>در حال بارگذاری...</div>;
|
||||
if (!slots.length) return (
|
||||
if (!sessions.length) return (
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>🏖️</div>
|
||||
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>این روز تعطیل است</div>
|
||||
@@ -326,16 +379,21 @@ function ScheduleView({
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '4px 0' }}>
|
||||
{slots.map((slot, i) => (
|
||||
<div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'center' }}>
|
||||
<SlotCard slot={slot} queryKey={queryKey} onBook={onBook} />
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
|
||||
fontSize: 12, color: 'var(--text-3)', fontWeight: 600, minWidth: 48,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--text-3)', display: 'block' }} />
|
||||
{slot.start_time}
|
||||
</div>
|
||||
{sessions.map((session, si) => (
|
||||
<div key={si} style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: si > 0 ? 16 : 0 }}>
|
||||
<SessionDivider startTime={session.start_time} endTime={session.end_time} count={session.slots.length} />
|
||||
{session.slots.map((slot, i) => (
|
||||
<div key={`${slot.start}-${i}`} style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'center' }}>
|
||||
<SlotCard slot={slot} queryKey={queryKey} onBook={onBook} />
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
|
||||
fontSize: 12, color: 'var(--text-3)', fontWeight: 600, minWidth: 48,
|
||||
}}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--text-3)', display: 'block' }} />
|
||||
{slot.start_time}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -452,7 +510,7 @@ export default function AppointmentsPage() {
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const [selectedDate, setSelectedDate] = useState(today);
|
||||
const [viewMode, setViewMode] = useState<'table' | 'schedule'>('table');
|
||||
const [viewMode, setViewMode] = useState<'table' | 'schedule'>('schedule');
|
||||
const [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
|
||||
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
|
||||
const [bookingHint, setBookingHint] = useState(false);
|
||||
@@ -490,29 +548,42 @@ export default function AppointmentsPage() {
|
||||
enabled: viewMode === 'schedule' && !!selectedDoctorUuid,
|
||||
});
|
||||
|
||||
// ── Merge slots + appointments for schedule view
|
||||
const mergedSlots: SlotItem[] = React.useMemo(() => {
|
||||
// ── Merge sessions + appointments for schedule view
|
||||
const mergedSessions: SessionGroup[] = React.useMemo(() => {
|
||||
if (viewMode !== 'schedule') return [];
|
||||
const rawSlots: any[] = (slotsQuery.data?.data as any)?.slots ?? [];
|
||||
const apptByStart = new Map<number, Appointment>();
|
||||
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
||||
|
||||
const CANCELLED_STATUSES = new Set(['cancelled_by_doctor', 'cancelled_by_user', 'cancelled_by_admin', 'auto_cancel_unpaid', 'no_show', 'expired']);
|
||||
const activeByStart = new Map<number, Appointment>();
|
||||
const cancelledByStart = new Map<number, Appointment>();
|
||||
|
||||
appointments.forEach(a => {
|
||||
const key = typeof a.slot_start === 'number' ? a.slot_start : parseInt(String(a.slot_start), 10);
|
||||
apptByStart.set(key, a);
|
||||
if (CANCELLED_STATUSES.has(a.status)) {
|
||||
const existing = cancelledByStart.get(key);
|
||||
if (!existing || a.created_at > existing.created_at) cancelledByStart.set(key, a);
|
||||
} else {
|
||||
activeByStart.set(key, a);
|
||||
}
|
||||
});
|
||||
|
||||
return rawSlots.map((s: any) => {
|
||||
const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10);
|
||||
const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10);
|
||||
const appt = apptByStart.get(slotStart) ?? null;
|
||||
return {
|
||||
start: slotStart,
|
||||
end: slotEnd,
|
||||
start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||
end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||
is_available: !appt,
|
||||
appointment: appt,
|
||||
};
|
||||
});
|
||||
return rawSessions.map((session: any) => ({
|
||||
start_time: session.start_time as string,
|
||||
end_time: session.end_time as string,
|
||||
slots: (session.slots as any[]).map((s: any) => {
|
||||
const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10);
|
||||
const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10);
|
||||
return {
|
||||
start: slotStart,
|
||||
end: slotEnd,
|
||||
start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||
end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||
is_available: s.is_available as boolean,
|
||||
appointment: activeByStart.get(slotStart) ?? null,
|
||||
cancelled_appointment: cancelledByStart.get(slotStart) ?? null,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
}, [viewMode, slotsQuery.data, appointments]);
|
||||
|
||||
// ── Handle slot click → open booking modal
|
||||
@@ -662,7 +733,7 @@ export default function AppointmentsPage() {
|
||||
</div>
|
||||
)}
|
||||
<ScheduleView
|
||||
slots={mergedSlots}
|
||||
sessions={mergedSessions}
|
||||
loading={apptQuery.isLoading || slotsQuery.isLoading}
|
||||
queryKey={apptQueryKey}
|
||||
onBook={handleSlotClick}
|
||||
|
||||
+26
-9
@@ -6,7 +6,7 @@
|
||||
|
||||
## GET `/api/v1/appointment-slots`
|
||||
|
||||
Get available appointment slots for a doctor on a specific date.
|
||||
Get all appointment slots (available and booked) for a doctor on a specific date.
|
||||
|
||||
**Permission:** `PUBLIC`
|
||||
|
||||
@@ -23,23 +23,40 @@ Get available appointment slots for a doctor on a specific date.
|
||||
"data": {
|
||||
"doctor_uuid": "550e8400-...",
|
||||
"date": "2024-06-15",
|
||||
"slots": [
|
||||
"sessions": [
|
||||
{
|
||||
"start": 1718438400,
|
||||
"end": 1718439600,
|
||||
"available": true
|
||||
"start_time": "09:00",
|
||||
"end_time": "13:00",
|
||||
"slots": [
|
||||
{
|
||||
"start": 1718438400,
|
||||
"end": 1718439600,
|
||||
"start_time": "09:00",
|
||||
"end_time": "09:20",
|
||||
"location_id": null,
|
||||
"is_available": true
|
||||
},
|
||||
{
|
||||
"start": 1718439600,
|
||||
"end": 1718440800,
|
||||
"start_time": "09:20",
|
||||
"end_time": "09:40",
|
||||
"location_id": null,
|
||||
"is_available": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"start": 1718439600,
|
||||
"end": 1718440800,
|
||||
"available": false
|
||||
"start_time": "15:00",
|
||||
"end_time": "17:00",
|
||||
"slots": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> All times are **Unix timestamps**. Slots are calculated from `WeeklySchedule` minus booked appointments, date overrides, and holidays.
|
||||
> Returns **all** slots grouped by work shift. `is_available: false` means the slot has an active (pending/confirmed) appointment. Session boundaries match the doctor's `WeeklySchedule` or date override config.
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|
||||
@@ -115,12 +115,12 @@ class AppointmentController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
||||
}
|
||||
|
||||
$slots = $this->slotCalculator->getAvailableSlots($doctor, $date);
|
||||
$sessions = $this->slotCalculator->getAllSlotsWithAvailability($doctor, $date);
|
||||
|
||||
return $this->success([
|
||||
'doctor_uuid' => $doctorUuid,
|
||||
'date' => $date,
|
||||
'slots' => $slots,
|
||||
'sessions' => $sessions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,43 @@ class SlotCalculatorService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns available slots for a doctor on a given date.
|
||||
* Returns available slots (flat array) for booking conflict checks.
|
||||
* Day index convention: 0=Saturday(شنبه), 1=Sunday, ..., 6=Friday(جمعه)
|
||||
*
|
||||
* @return array[] [{start: int, end: int, start_time: string, end_time: string, location_id: int|null}]
|
||||
* @return array[] [{start, end, start_time, end_time, location_id}]
|
||||
*/
|
||||
public function getAvailableSlots(Doctor $doctor, string $date): array
|
||||
{
|
||||
$sessions = $this->buildAllSessions($doctor, $date);
|
||||
if (empty($sessions)) return [];
|
||||
$flat = array_merge(...array_map(fn($s) => $s['slots'], $sessions));
|
||||
return $this->filterBookedSlots($doctor, $flat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns sessions grouped by shift, each slot tagged with is_available.
|
||||
* Used by the schedule view to show real shift boundaries.
|
||||
*
|
||||
* @return array[] [{start_time, end_time, slots: [{start, end, start_time, end_time, location_id, is_available}]}]
|
||||
*/
|
||||
public function getAllSlotsWithAvailability(Doctor $doctor, string $date): array
|
||||
{
|
||||
$sessions = $this->buildAllSessions($doctor, $date);
|
||||
return array_map(fn(array $session) => [
|
||||
'start_time' => $session['start_time'],
|
||||
'end_time' => $session['end_time'],
|
||||
'slots' => array_map(fn(array $slot) => array_merge($slot, [
|
||||
'is_available' => !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']),
|
||||
]), $session['slots']),
|
||||
], $sessions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Core: build all sessions with their slots, grouped by shift.
|
||||
*
|
||||
* @return array[] [{start_time: string, end_time: string, slots: array[]}]
|
||||
*/
|
||||
private function buildAllSessions(Doctor $doctor, string $date): array
|
||||
{
|
||||
$dayStart = (int) strtotime($date . ' 00:00:00');
|
||||
$dayEnd = $dayStart + 86400;
|
||||
@@ -38,10 +69,7 @@ class SlotCalculatorService
|
||||
foreach ($this->overrideRepo->findByDoctor($doctor) as $override) {
|
||||
if (date('Y-m-d', $override->getDate()) === $date) {
|
||||
if (!$override->isActive()) return [];
|
||||
return $this->filterBookedSlots(
|
||||
$doctor,
|
||||
$this->buildFlatSlots($override->getSetting() ?? [], $dayStart)
|
||||
);
|
||||
return $this->buildSessionsFromOverride($override->getSetting() ?? [], $dayStart);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,30 +84,79 @@ class SlotCalculatorService
|
||||
$dayConf = $schedule->getSetting()[$dayKey] ?? null;
|
||||
if ($dayConf === null) return [];
|
||||
|
||||
$sessions = $dayConf['sessions'] ?? [];
|
||||
// Sort active sessions by start_time, skip overlapping ones
|
||||
$activeSessions = array_filter($sessions, fn($s) => $s['active'] ?? false);
|
||||
$sessionConfigs = $dayConf['sessions'] ?? [];
|
||||
$activeSessions = array_filter($sessionConfigs, fn($s) => $s['active'] ?? false);
|
||||
usort($activeSessions, fn($a, $b) =>
|
||||
$this->parseTime($a['start_time'] ?? '00:00') <=> $this->parseTime($b['start_time'] ?? '00:00')
|
||||
);
|
||||
|
||||
$allSlots = [];
|
||||
$prevEnd = 0;
|
||||
$result = [];
|
||||
$prevEnd = 0;
|
||||
foreach ($activeSessions as $session) {
|
||||
$sessionStart = $this->parseTime($session['start_time'] ?? '00:00');
|
||||
if ($sessionStart < $prevEnd) continue; // skip overlapping session
|
||||
$allSlots = array_merge($allSlots, $this->buildSessionSlots($session, $dayStart));
|
||||
$prevEnd = $this->parseTime($session['end_time'] ?? '00:00');
|
||||
$slots = $this->buildSessionSlots($session, $dayStart);
|
||||
if (!empty($slots)) {
|
||||
$result[] = [
|
||||
'start_time' => $session['start_time'] ?? '00:00',
|
||||
'end_time' => $session['end_time'] ?? '00:00',
|
||||
'slots' => $slots,
|
||||
];
|
||||
}
|
||||
$prevEnd = $this->parseTime($session['end_time'] ?? '00:00');
|
||||
}
|
||||
|
||||
usort($allSlots, fn($a, $b) => $a['start'] - $b['start']);
|
||||
|
||||
return $this->filterBookedSlots($doctor, $allSlots);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build slots from a morning/evening session config.
|
||||
* Supports: rest breaks, patient limits.
|
||||
* Build sessions from date-override config.
|
||||
* Supports new SessionConfig format {start_time, end_time, ...} and legacy {start, end, duration}.
|
||||
*/
|
||||
private function buildSessionsFromOverride(array $slotConfigs, int $dayStart): array
|
||||
{
|
||||
$sessions = [];
|
||||
foreach ($slotConfigs as $config) {
|
||||
if (isset($config['start_time'])) {
|
||||
$slots = $this->buildSessionSlots(array_merge(['active' => true], $config), $dayStart);
|
||||
if (!empty($slots)) {
|
||||
$sessions[] = [
|
||||
'start_time' => $config['start_time'],
|
||||
'end_time' => $config['end_time'] ?? '00:00',
|
||||
'slots' => $slots,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// Legacy flat format: {start, end, duration}
|
||||
$startSec = $this->parseTime($config['start'] ?? '00:00');
|
||||
$endSec = $this->parseTime($config['end'] ?? '00:00');
|
||||
$duration = (int)($config['duration'] ?? 30) * 60;
|
||||
if ($duration <= 0 || $endSec <= $startSec) continue;
|
||||
$slots = [];
|
||||
for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) {
|
||||
$slots[] = [
|
||||
'start' => $dayStart + $t,
|
||||
'end' => $dayStart + $t + $duration,
|
||||
'start_time' => gmdate('H:i', $t),
|
||||
'end_time' => gmdate('H:i', $t + $duration),
|
||||
'location_id' => null,
|
||||
];
|
||||
}
|
||||
if (!empty($slots)) {
|
||||
$sessions[] = [
|
||||
'start_time' => $config['start'] ?? '00:00',
|
||||
'end_time' => $config['end'] ?? '00:00',
|
||||
'slots' => $slots,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build individual slot entries from a session config (weekly or override).
|
||||
* Handles rest breaks and patient limits.
|
||||
*/
|
||||
private function buildSessionSlots(array $session, int $dayStart): array
|
||||
{
|
||||
@@ -87,8 +164,8 @@ class SlotCalculatorService
|
||||
$endSec = $this->parseTime($session['end_time'] ?? '00:00');
|
||||
$dur = (int)($session['duration_per_patient'] ?? 20) * 60;
|
||||
$hasRest = (bool)($session['has_rest'] ?? false);
|
||||
$restInt = (int)($session['rest_interval'] ?? 60) * 60; // convert min → sec
|
||||
$restDur = (int)($session['time_to_rest'] ?? 10) * 60; // convert min → sec
|
||||
$restInt = (int)($session['rest_interval'] ?? 60) * 60;
|
||||
$restDur = (int)($session['time_to_rest'] ?? 10) * 60;
|
||||
$limit = isset($session['patient_limit']) && $session['patient_limit'] !== null
|
||||
? (int)$session['patient_limit'] : null;
|
||||
$locationId = isset($session['location_id']) ? (int)$session['location_id'] : null;
|
||||
@@ -97,13 +174,12 @@ class SlotCalculatorService
|
||||
|
||||
$slots = [];
|
||||
$currentSec = $startSec;
|
||||
$elapsedWork = 0; // seconds worked since last rest
|
||||
$elapsedWork = 0;
|
||||
$patientCount = 0;
|
||||
|
||||
while ($currentSec + $dur <= $endSec) {
|
||||
if ($limit !== null && $patientCount >= $limit) break;
|
||||
|
||||
// Insert rest break if needed
|
||||
if ($hasRest && $restInt > 0 && $elapsedWork > 0 && $elapsedWork >= $restInt) {
|
||||
$currentSec += $restDur;
|
||||
$elapsedWork = 0;
|
||||
@@ -126,41 +202,6 @@ class SlotCalculatorService
|
||||
return $slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build slots from date-override custom_slots.
|
||||
* Supports new SessionConfig format {start_time, end_time, duration_per_patient, ...}
|
||||
* and legacy flat format {start, end, duration} for backward compatibility.
|
||||
*/
|
||||
private function buildFlatSlots(array $slotConfigs, int $dayStart): array
|
||||
{
|
||||
$slots = [];
|
||||
foreach ($slotConfigs as $config) {
|
||||
if (isset($config['start_time'])) {
|
||||
// New SessionConfig format — reuse the same logic as weekly sessions
|
||||
$slots = array_merge($slots, $this->buildSessionSlots(
|
||||
array_merge(['active' => true], $config),
|
||||
$dayStart
|
||||
));
|
||||
} else {
|
||||
// Legacy flat format: {start, end, duration}
|
||||
$startSec = $this->parseTime($config['start'] ?? '00:00');
|
||||
$endSec = $this->parseTime($config['end'] ?? '00:00');
|
||||
$duration = (int)($config['duration'] ?? 30) * 60;
|
||||
if ($duration <= 0 || $endSec <= $startSec) continue;
|
||||
for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) {
|
||||
$slots[] = [
|
||||
'start' => $dayStart + $t,
|
||||
'end' => $dayStart + $t + $duration,
|
||||
'start_time' => gmdate('H:i', $t),
|
||||
'end_time' => gmdate('H:i', $t + $duration),
|
||||
'location_id' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $slots;
|
||||
}
|
||||
|
||||
private function filterBookedSlots(Doctor $doctor, array $slots): array
|
||||
{
|
||||
return array_values(array_filter($slots, fn(array $slot): bool =>
|
||||
|
||||
Reference in New Issue
Block a user