diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index 70017a3f..3150bd2e 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -11,7 +11,7 @@ import { ClipboardDocumentIcon, EllipsisVerticalIcon, StarIcon, BuildingOfficeIcon, MapPinIcon, AcademicCapIcon, UserIcon, PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon, - CheckCircleIcon, XMarkIcon, + CheckCircleIcon, XMarkIcon, ExclamationTriangleIcon, } from '@heroicons/react/24/outline'; import { StarIcon as StarSolid } from '@heroicons/react/24/solid'; import { toast } from 'sonner'; @@ -66,12 +66,24 @@ interface AddressData { // ── Schedule types ───────────────────────────────────────────────────────── interface SlotConfig { start: string; end: string; duration: number; } -interface DayConfig { active: boolean; slots: SlotConfig[]; } -type ScheduleMap = Record; -interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: ScheduleMap; } interface DateOverrideData { uuid: string; doctor_uuid: string; date: number; active: boolean; reason: string | null; custom_slots: SlotConfig[]; } interface HolidayData { uuid: string; doctor_uuid: string; start_date: number; end_date: number; active: boolean; reason: string | null; } +interface SessionConfig { + active: boolean; + location_id: number | null; + start_time: string; + end_time: string; + duration_per_patient: number; + has_rest: boolean; + rest_interval: number; + time_to_rest: number; + patient_limit: number | null; +} +interface NewDayConfig { sessions: SessionConfig[]; } +type NewScheduleMap = Record; +interface WeeklyScheduleData { uuid: string; doctor_uuid: string; schedule: NewScheduleMap; } + // ── Constants ────────────────────────────────────────────────────────────── const DEGREE_LABELS: Record = { @@ -252,18 +264,62 @@ function PersianDateInput({ value, onChange, minDate, placeholder }: { } const SCHEDULE_DAYS = [ - { key: 'saturday', label: 'شنبه' }, - { key: 'sunday', label: 'یکشنبه' }, - { key: 'monday', label: 'دوشنبه' }, - { key: 'tuesday', label: 'سه‌شنبه' }, - { key: 'wednesday', label: 'چهارشنبه' }, - { key: 'thursday', label: 'پنجشنبه' }, - { key: 'friday', label: 'جمعه' }, + { key: '0', label: 'شنبه' }, + { key: '1', label: 'یکشنبه' }, + { key: '2', label: 'دوشنبه' }, + { key: '3', label: 'سه‌شنبه' }, + { key: '4', label: 'چهارشنبه' }, + { key: '5', label: 'پنجشنبه' }, + { key: '6', label: 'جمعه' }, ]; -const DURATION_OPTS = [15, 20, 30, 45, 60]; -const EMPTY_SCHEDULE: ScheduleMap = Object.fromEntries( - SCHEDULE_DAYS.map(d => [d.key, { active: false, slots: [] }]) +const DURATION_OPTS = [10, 15, 20, 30, 45, 60]; +const DEFAULT_SESSION: SessionConfig = { + active: true, location_id: null, + start_time: '09:00', end_time: '13:00', + duration_per_patient: 20, + has_rest: false, rest_interval: 60, time_to_rest: 10, + patient_limit: null, +}; +const EMPTY_NEW_SCHEDULE: NewScheduleMap = Object.fromEntries( + SCHEDULE_DAYS.map(d => [d.key, { sessions: [] }]) ); + +function parseMinutes(t: string): number { + const [h, m] = t.split(':').map(Number); + return h * 60 + m; +} + +function hasOverlap(sessions: SessionConfig[]): boolean { + const active = sessions.filter(s => s.active); + const sorted = [...active].sort((a, b) => parseMinutes(a.start_time) - parseMinutes(b.start_time)); + for (let i = 0; i < sorted.length - 1; i++) { + if (parseMinutes(sorted[i].end_time) > parseMinutes(sorted[i + 1].start_time)) return true; + } + return false; +} + +function addMinutes(time: string, mins: number): string { + const total = Math.min(parseMinutes(time) + mins, 23 * 60 + 59); + const h = Math.floor(total / 60).toString().padStart(2, '0'); + const m = (total % 60).toString().padStart(2, '0'); + return `${h}:${m}`; +} + +function calcSlotCount(session: SessionConfig): number { + const totalMins = parseMinutes(session.end_time) - parseMinutes(session.start_time); + if (totalMins <= 0 || session.duration_per_patient <= 0) return 0; + const dur = session.duration_per_patient; + const limit = session.patient_limit ?? Infinity; + let current = 0, elapsedWork = 0, count = 0; + while (current + dur <= totalMins) { + if (count >= limit) break; + if (session.has_rest && session.rest_interval > 0 && elapsedWork > 0 && elapsedWork >= session.rest_interval) { + current += session.time_to_rest; elapsedWork = 0; continue; + } + current += dur; elapsedWork += dur; count++; + } + return count; +} function tsToDate(ts: number): string { return new Date(ts * 1000).toISOString().slice(0, 10); } @@ -953,12 +1009,129 @@ function SlotEditor({ slots, onChange }: { ); } +function SessionEditor({ session, onChange, onRemove, addresses }: { + session: SessionConfig; + onChange: (s: SessionConfig) => void; + onRemove: () => void; + addresses: AddressData[]; +}) { + const upd = (k: K, v: SessionConfig[K]) => + onChange({ ...session, [k]: v }); + const hasAdvanced = session.has_rest || session.patient_limit !== null; + const [showAdvanced, setShowAdvanced] = useState(hasAdvanced); + const slotCount = session.active ? calcSlotCount(session) : 0; + + return ( +
+ {/* ─ row اصلی */} +
+ +
+ از + upd('start_time', e.target.value)} + className="cp-input h-8 w-28 text-sm font-mono text-center px-2" /> + تا + upd('end_time', e.target.value)} + className="cp-input h-8 w-28 text-sm font-mono text-center px-2" /> +
+ + {session.active && slotCount > 0 && ( + + {slotCount} نوبت + + )} + +
+ + {session.active && ( +
+ {/* ─ مکان مطب — اجباری */} + {addresses.length > 0 && ( +
+ +
+ + {!session.location_id && ( +

انتخاب مکان مطب الزامی است

+ )} +
+
+ )} + + {/* ─ تنظیمات پیشرفته collapse */} + + {showAdvanced && ( +
+
+ + upd('patient_limit', e.target.value ? Number(e.target.value) : null)} + className="cp-input h-8 text-sm w-full" /> +
+
+ + {session.has_rest && ( +
+
+ هر + upd('rest_interval', Number(e.target.value))} + className="cp-input h-7 w-16 text-sm text-center px-1" /> + دقیقه کار +
+
+ استراحت + upd('time_to_rest', Number(e.target.value))} + className="cp-input h-7 w-16 text-sm text-center px-1" /> + دقیقه +
+
+ )} +
+
+ )} +
+ )} +
+ ); +} + // ── Weekly Schedule Tab ──────────────────────────────────────────────────── -function WeeklyScheduleTab({ doctorUuid }: { doctorUuid: string }) { +function WeeklyScheduleTab({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) { const qc = useQueryClient(); - const [scheduleMap, setScheduleMap] = useState(EMPTY_SCHEDULE); + const [scheduleMap, setScheduleMap] = useState(EMPTY_NEW_SCHEDULE); const [scheduleUuid, setScheduleUuid] = useState(null); + const [expandedDay, setExpandedDay] = useState(null); const scheduleQ = useQuery({ queryKey: ['doctor-schedule', doctorUuid], @@ -970,16 +1143,44 @@ function WeeklyScheduleTab({ doctorUuid }: { doctorUuid: string }) { useEffect(() => { if (scheduleQ.data) { const d: WeeklyScheduleData = scheduleQ.data?.data?.data ?? scheduleQ.data?.data; - if (d?.schedule) { setScheduleMap({ ...EMPTY_SCHEDULE, ...d.schedule }); setScheduleUuid(d.uuid); } + if (d?.schedule) { + const merged: NewScheduleMap = { ...EMPTY_NEW_SCHEDULE }; + for (const key of Object.keys(d.schedule)) { + const raw = d.schedule[key] as any; + if (raw?.sessions) merged[key] = { sessions: raw.sessions }; + } + setScheduleMap(merged); + setScheduleUuid(d.uuid); + } } else if (scheduleQ.error instanceof ApiError && scheduleQ.error.status === 404) { - setScheduleMap(EMPTY_SCHEDULE); setScheduleUuid(null); + setScheduleMap(EMPTY_NEW_SCHEDULE); setScheduleUuid(null); } }, [scheduleQ.data, scheduleQ.error]); + const overlapDays = useMemo(() => + Object.fromEntries(SCHEDULE_DAYS.map(d => [d.key, hasOverlap(scheduleMap[d.key]?.sessions ?? [])])) + , [scheduleMap]); + const hasAnyOverlap = Object.values(overlapDays).some(Boolean); + + const totalSlots = useMemo(() => + SCHEDULE_DAYS.reduce((sum, d) => { + const sessions = scheduleMap[d.key]?.sessions ?? []; + return sum + sessions.filter(s => s.active).reduce((s2, s) => s2 + calcSlotCount(s), 0); + }, 0) + , [scheduleMap]); + + const missingLocation = addresses.length > 0 && SCHEDULE_DAYS.some(d => + (scheduleMap[d.key]?.sessions ?? []).some(s => s.active && s.location_id === null) + ); + const saveMut = useMutation({ - mutationFn: () => scheduleUuid - ? api.patch>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap }) - : api.post>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap }), + mutationFn: () => { + if (hasAnyOverlap) throw new Error('تداخل زمانی در برنامه وجود دارد'); + if (missingLocation) throw new Error('مکان مطب برای همه بازه‌های فعال الزامی است'); + return scheduleUuid + ? api.patch>(`/api/v1/appointment-settings/weekly-schedule/${doctorUuid}`, { schedule: scheduleMap }) + : api.post>('/api/v1/appointment-settings/weekly-schedule', { doctor_uuid: doctorUuid, schedule: scheduleMap }); + }, onSuccess: (res) => { const d: WeeklyScheduleData = res?.data?.data ?? res?.data; if (d?.uuid && !scheduleUuid) setScheduleUuid(d.uuid); @@ -989,8 +1190,26 @@ function WeeklyScheduleTab({ doctorUuid }: { doctorUuid: string }) { onError: (e: Error) => toast.error(e.message), }); - const setDay = (key: string, patch: Partial) => - setScheduleMap(prev => ({ ...prev, [key]: { ...prev[key], ...patch } })); + const setDaySessions = (key: string, sessions: SessionConfig[]) => + setScheduleMap(prev => ({ ...prev, [key]: { sessions } })); + + const addSession = (key: string) => { + const existing = scheduleMap[key]?.sessions ?? []; + const lastEnd = existing.length > 0 + ? existing.reduce((max, s) => parseMinutes(s.end_time) > parseMinutes(max) ? s.end_time : max, '00:00') + : '09:00'; + const newStart = existing.length > 0 ? addMinutes(lastEnd, 30) : '09:00'; + const newEnd = addMinutes(newStart, 240); + const defaultLoc = addresses.length > 0 ? Number(addresses[0].id) : null; + setDaySessions(key, [...existing, { ...DEFAULT_SESSION, start_time: newStart, end_time: newEnd, location_id: defaultLoc }]); + setExpandedDay(key); + }; + + const removeSession = (key: string, idx: number) => + setDaySessions(key, (scheduleMap[key]?.sessions ?? []).filter((_, i) => i !== idx)); + + const updateSession = (key: string, idx: number, s: SessionConfig) => + setDaySessions(key, (scheduleMap[key]?.sessions ?? []).map((old, i) => i === idx ? s : old)); if (scheduleQ.isLoading) return (
{Array.from({ length: 4 }).map((_, i) =>
)}
@@ -998,44 +1217,99 @@ function WeeklyScheduleTab({ doctorUuid }: { doctorUuid: string }) { return (
+ {/* ─ خلاصه کل هفته */} + {totalSlots > 0 && ( +
+ + + مجموع {totalSlots} نوبت در هفته + +
+ )} + {SCHEDULE_DAYS.map(day => { - const cfg = scheduleMap[day.key] ?? { active: false, slots: [] }; - const totalSlots = cfg.slots.reduce((acc, s) => { - const [sh, sm] = s.start.split(':').map(Number); - const [eh, em] = s.end.split(':').map(Number); - const mins = (eh * 60 + em) - (sh * 60 + sm); - return acc + Math.floor(mins / Math.max(s.duration, 1)); - }, 0); + const sessions = scheduleMap[day.key]?.sessions ?? []; + const isOverlap = overlapDays[day.key]; + const isExpanded = expandedDay === day.key; + const activeSessions = sessions.filter(s => s.active); + const daySlots = activeSessions.reduce((sum, s) => sum + calcSlotCount(s), 0); + const hasAny = sessions.length > 0; + return ( -
-
- - {day.label} - {!cfg.active && تعطیل} - {cfg.active && cfg.slots.length > 0 && ( - - {cfg.slots.length} بازه · {totalSlots} نوبت - - )} - {cfg.active && cfg.slots.length === 0 && ( - هنوز بازه‌ای تنظیم نشده - )} +
+ {/* ─ header هر روز */} +
setExpandedDay(isExpanded ? null : day.key)} + > + + {day.label} + + + {/* chips بازه‌های active */} +
+ {sessions.length === 0 && ( + تعطیل + )} + {activeSessions.map((s, i) => ( + + {s.start_time}–{s.end_time} + + ))} + {daySlots > 0 && ( + + ({daySlots} نوبت) + + )} + {isOverlap && ( + + تداخل + + )} +
+ +
+ + +
- {cfg.active && ( -
- setDay(day.key, { slots })} /> + + {/* ─ محتوای expanded */} + {isExpanded && ( +
+ {sessions.length === 0 ? ( +
+

هیچ بازه‌ای برای این روز تنظیم نشده

+ +
+ ) : sessions.map((session, idx) => ( + updateSession(day.key, idx, s)} + onRemove={() => removeSession(day.key, idx)} /> + ))}
)}
); })} -
-
-
- - - {active ? 'این روز فعال است (نوبت‌دهی می‌شود)' : 'این روز تعطیل است (بدون نوبت)'} - + + {/* ─ نوع override */} +
+ +
+ + +
+
setReason(e.target.value)} - placeholder="مثال: شیفت اضطراری، جلسه..." className="cp-input" /> + placeholder={overrideType === 'closed' ? 'مثال: سفر، مریضی، کنگره...' : 'مثال: شیفت اضطراری، ویزیت خاص...'} + className="cp-input" />
- {active ? ( + + {overrideType === 'custom' && (
- +

اگر خالی بماند از برنامه هفتگی استفاده می‌شود

- ) : ( -
-

- در این روز هیچ نوبتی داده نخواهد شد، حتی اگر در برنامه هفتگی فعال باشد. + )} + + {overrideType === 'closed' && ( +

+ +

+ در این روز هیچ نوبتی داده نخواهد شد، حتی اگر در برنامه هفتگی ساعات کاری داشته باشید.

)} @@ -1175,26 +1472,29 @@ function DateOverridesTab({ doctorUuid }: { doctorUuid: string }) { ) : (
{overrides.map(ov => ( -
+
{formatPersianDate(tsToDate(ov.date))} - - {ov.active ? 'فعال' : 'تعطیل'} - - {ov.custom_slots.length > 0 && ( - {ov.custom_slots.length} بازه سفارشی + {ov.active ? ( + + {ov.custom_slots.length > 0 ? `${ov.custom_slots.length} بازه سفارشی` : 'ساعات خاص'} + + ) : ( + + تعطیل + )}
{ov.reason &&

{ov.reason}

}
-
+
@@ -1250,7 +1550,7 @@ function HolidayModal({ open, onClose, existing, doctorUuid, onSaved }: { }); return ( - @@ -1334,31 +1634,43 @@ function HolidaysTab({ doctorUuid }: { doctorUuid: string }) { ) : (
{holidays.map(h => { - const sameDay = tsToDate(h.start_date) === tsToDate(h.end_date); + const now = Math.floor(Date.now() / 1000); + const isPast = h.end_date < now; + const isCurrent = h.start_date <= now && h.end_date >= now; + const sameDay = tsToDate(h.start_date) === tsToDate(h.end_date); return ( -
+
- + {sameDay ? formatPersianDate(tsToDate(h.start_date)) : `${formatPersianDate(tsToDate(h.start_date))} تا ${formatPersianDate(tsToDate(h.end_date))}`} - - {h.active ? 'فعال' : 'غیرفعال'} - + {isCurrent && h.active && ( + در جریان + )} + {!isCurrent && !isPast && h.active && ( + آینده + )} + {isPast && ( + گذشته + )} + {!h.active && ( + غیرفعال + )}
{h.reason &&

{h.reason}

}
-
+
@@ -1385,7 +1697,7 @@ const SCHEDULE_TABS = [ { id: 'holidays' as const, label: 'تعطیلات' }, ]; -function ScheduleSection({ doctorUuid }: { doctorUuid: string }) { +function ScheduleSection({ doctorUuid, addresses }: { doctorUuid: string; addresses: AddressData[] }) { const [tab, setTab] = useState<'weekly' | 'overrides' | 'holidays'>('weekly'); return (
@@ -1402,7 +1714,7 @@ function ScheduleSection({ doctorUuid }: { doctorUuid: string }) { ))}
- {tab === 'weekly' && } + {tab === 'weekly' && } {tab === 'overrides' && } {tab === 'holidays' && }
@@ -1765,7 +2077,7 @@ export default function DoctorDetailPage() { )}
- {uuid && } + {uuid && } {doctor.clinics && doctor.clinics.length > 0 && (
diff --git a/src/Appointment/Service/SlotCalculatorService.php b/src/Appointment/Service/SlotCalculatorService.php index 619691b7..bb136971 100644 --- a/src/Appointment/Service/SlotCalculatorService.php +++ b/src/Appointment/Service/SlotCalculatorService.php @@ -2,20 +2,15 @@ namespace App\Appointment\Service; -use App\Appointment\Entity\Appointment; use App\Appointment\Repository\AppointmentRepository; use App\Appointment\Repository\DateOverrideRepository; use App\Appointment\Repository\HolidayRepository; use App\Appointment\Repository\WeeklyScheduleRepository; use App\Doctor\Entity\Doctor; + class SlotCalculatorService { - private const DAY_MAP = [ - 0 => 'sunday', 1 => 'monday', 2 => 'tuesday', 3 => 'wednesday', - 4 => 'thursday', 5 => 'friday', 6 => 'saturday', - ]; - public function __construct( private readonly WeeklyScheduleRepository $scheduleRepo, private readonly DateOverrideRepository $overrideRepo, @@ -25,64 +20,133 @@ class SlotCalculatorService /** * Returns available slots for a doctor on a given date. - * @param string $date 'Y-m-d' format - * @return array[] [{start: int, end: int, start_time: string, end_time: string}] + * 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}] */ public function getAvailableSlots(Doctor $doctor, string $date): array { $dayStart = (int) strtotime($date . ' 00:00:00'); $dayEnd = $dayStart + 86400; - // Check if in holiday - $holidays = $this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1); - if (!empty($holidays)) return []; + // 1. Blocked by holiday + if (!empty($this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1))) { + return []; + } - // Check date override first - $overrides = $this->overrideRepo->findByDoctor($doctor); - foreach ($overrides as $override) { - $overDate = date('Y-m-d', $override->getDate()); - if ($overDate === $date) { + // 2. Date override takes precedence over weekly schedule + foreach ($this->overrideRepo->findByDoctor($doctor) as $override) { + if (date('Y-m-d', $override->getDate()) === $date) { if (!$override->isActive()) return []; - return $this->buildSlots($override->getSetting() ?? [], $dayStart); + return $this->filterBookedSlots( + $doctor, + $this->buildFlatSlots($override->getSetting() ?? [], $dayStart) + ); } } - // Fall back to weekly schedule + // 3. Weekly schedule $schedule = $this->scheduleRepo->findByDoctor($doctor); if ($schedule === null) return []; - $dow = (int) date('w', $dayStart); - $dayName = self::DAY_MAP[$dow]; - $setting = $schedule->getSetting(); + // Convert PHP date('w') (0=Sunday) to Iranian week index (0=Saturday) + $phpDow = (int) date('w', $dayStart); + $dayKey = (string)(($phpDow + 1) % 7); - $dayConfig = $setting[$dayName] ?? null; - if ($dayConfig === null || !($dayConfig['active'] ?? false)) return []; + $dayConf = $schedule->getSetting()[$dayKey] ?? null; + if ($dayConf === null) return []; - $rawSlots = $this->buildSlots($dayConfig['slots'] ?? [], $dayStart); + $sessions = $dayConf['sessions'] ?? []; + // Sort active sessions by start_time, skip overlapping ones + $activeSessions = array_filter($sessions, fn($s) => $s['active'] ?? false); + usort($activeSessions, fn($a, $b) => + $this->parseTime($a['start_time'] ?? '00:00') <=> $this->parseTime($b['start_time'] ?? '00:00') + ); - // Filter out already-booked slots - return $this->filterBookedSlots($doctor, $rawSlots); + $allSlots = []; + $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'); + } + + usort($allSlots, fn($a, $b) => $a['start'] - $b['start']); + + return $this->filterBookedSlots($doctor, $allSlots); } - /** @return array[] */ - private function buildSlots(array $slotConfigs, int $dayStart): array + /** + * Build slots from a morning/evening session config. + * Supports: rest breaks, patient limits. + */ + private function buildSessionSlots(array $session, int $dayStart): array + { + $startSec = $this->parseTime($session['start_time'] ?? '00:00'); + $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 + $limit = isset($session['patient_limit']) && $session['patient_limit'] !== null + ? (int)$session['patient_limit'] : null; + $locationId = isset($session['location_id']) ? (int)$session['location_id'] : null; + + if ($dur <= 0 || $endSec <= $startSec) return []; + + $slots = []; + $currentSec = $startSec; + $elapsedWork = 0; // seconds worked since last rest + $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; + continue; + } + + $slots[] = [ + 'start' => $dayStart + $currentSec, + 'end' => $dayStart + $currentSec + $dur, + 'start_time' => gmdate('H:i', $currentSec), + 'end_time' => gmdate('H:i', $currentSec + $dur), + 'location_id' => $locationId, + ]; + + $currentSec += $dur; + $elapsedWork += $dur; + $patientCount += 1; + } + + return $slots; + } + + /** + * Build slots from flat date-override format: [{start, end, duration}] + * Kept for backward-compatibility with DateOverride.custom_slots. + */ + private function buildFlatSlots(array $slotConfigs, int $dayStart): array { $slots = []; foreach ($slotConfigs as $config) { $startSec = $this->parseTime($config['start'] ?? '00:00'); $endSec = $this->parseTime($config['end'] ?? '00:00'); - $duration = (int) ($config['duration'] ?? 30) * 60; + $duration = (int)($config['duration'] ?? 30) * 60; if ($duration <= 0 || $endSec <= $startSec) continue; for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) { - $slotStart = $dayStart + $t; - $slotEnd = $slotStart + $duration; - $slots[] = [ - 'start' => $slotStart, - 'end' => $slotEnd, - 'start_time' => gmdate('H:i', $t), - 'end_time' => gmdate('H:i', $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, ]; } } @@ -91,9 +155,9 @@ class SlotCalculatorService private function filterBookedSlots(Doctor $doctor, array $slots): array { - return array_values(array_filter($slots, function (array $slot) use ($doctor): bool { - return !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']); - })); + return array_values(array_filter($slots, fn(array $slot): bool => + !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']) + )); } private function parseTime(string $time): int