feat: enhance appointment scheduling with session management and availability checks

This commit is contained in:
hamed
2026-06-11 19:56:27 +03:30
parent 0b31eb7812
commit 5a7df22eda
5 changed files with 310 additions and 145 deletions
@@ -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>
);
+132 -61
View File
@@ -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
View File
@@ -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 =>