diff --git a/assets/admin/components/ui/AppointmentStatusDropdown.tsx b/assets/admin/components/ui/AppointmentStatusDropdown.tsx index 4ac9fa65..a81abdf0 100644 --- a/assets/admin/components/ui/AppointmentStatusDropdown.tsx +++ b/assets/admin/components/ui/AppointmentStatusDropdown.tsx @@ -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(null); + const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null); + const btnRef = useRef(null); + const menuRef = useRef(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 ( -
+
- {open && nextStatuses.length > 0 && ( -
+ {open && menuPos && nextStatuses.length > 0 && ReactDOM.createPortal( +
{nextStatuses.map(s => { const sm = STATUS_META[s] ?? { label: s, color: '#9ca3af' }; - const isCurrent = s === currentStatus; return ( ); })} -
+
, + document.body )}
); diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 39222b64..fc335a05 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -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 ( +
+ + {sm.label} + {appt.patient_name || '—'} + {appt.patient_mobile} +
+ ); } 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 ( +
+
+ +
+ {slot.start_time} — {slot.end_time} +
+
+
+
+ + {a.patient_name || '—'} +
+
+ + {a.patient_mobile} +
+
+
+ ); + } + + // Available slot — possibly with cancelled history + return ( +
+ {slot.cancelled_appointment && }
onBook(slot)} style={{ @@ -277,46 +337,39 @@ function SlotCard({ slot, queryKey, onBook }: { slot: SlotItem; queryKey: unknow > نوبت جدید
- ); - } +
+ ); +} - 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 ( -
-
- -
- {slot.start_time} — {slot.end_time} -
-
-
-
- - {a.patient_name || '—'} -
-
- - {a.patient_mobile} -
-
+
+ {icon} + {label} + + {startTime} — {endTime} + + {count} نوبت +
); } 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
در حال بارگذاری...
; - if (!slots.length) return ( + if (!sessions.length) return (
🏖️
این روز تعطیل است
@@ -326,16 +379,21 @@ function ScheduleView({ return (
- {slots.map((slot, i) => ( -
- -
- - {slot.start_time} -
+ {sessions.map((session, si) => ( +
0 ? 16 : 0 }}> + + {session.slots.map((slot, i) => ( +
+ +
+ + {slot.start_time} +
+
+ ))}
))}
@@ -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(isDoctor && dbUuid ? dbUuid : ''); const [bookingSlot, setBookingSlot] = useState(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(); + 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(); + const cancelledByStart = new Map(); + 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() {
)} 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 | diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 36e7dc62..4eade752 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -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, ]); } diff --git a/src/Appointment/Service/SlotCalculatorService.php b/src/Appointment/Service/SlotCalculatorService.php index d975d100..54dfd030 100644 --- a/src/Appointment/Service/SlotCalculatorService.php +++ b/src/Appointment/Service/SlotCalculatorService.php @@ -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 =>