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 React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -28,16 +29,39 @@ interface Props {
|
|||||||
|
|
||||||
export default function AppointmentStatusDropdown({ uuid, currentStatus, version, queryKey }: Props) {
|
export default function AppointmentStatusDropdown({ uuid, currentStatus, version, queryKey }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
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();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
function handler(e: MouseEvent) {
|
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);
|
document.addEventListener('mousedown', handler);
|
||||||
return () => document.removeEventListener('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({
|
const mutation = useMutation({
|
||||||
mutationFn: (newStatus: string) =>
|
mutationFn: (newStatus: string) =>
|
||||||
@@ -52,10 +76,19 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
|
|||||||
const meta = STATUS_META[currentStatus] ?? { label: currentStatus, color: '#9ca3af' };
|
const meta = STATUS_META[currentStatus] ?? { label: currentStatus, color: '#9ca3af' };
|
||||||
const nextStatuses = TRANSITIONS[currentStatus] ?? [];
|
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 (
|
return (
|
||||||
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(o => !o)}
|
ref={btnRef}
|
||||||
|
onClick={handleToggle}
|
||||||
disabled={nextStatuses.length === 0}
|
disabled={nextStatuses.length === 0}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||||
@@ -74,16 +107,19 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && nextStatuses.length > 0 && (
|
{open && menuPos && nextStatuses.length > 0 && ReactDOM.createPortal(
|
||||||
<div style={{
|
<div
|
||||||
position: 'absolute', top: '100%', right: 0, marginTop: 4, zIndex: 100,
|
ref={menuRef}
|
||||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
style={{
|
||||||
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
|
position: 'fixed', top: menuPos.top, right: menuPos.right,
|
||||||
minWidth: 160, overflow: 'hidden',
|
zIndex: 9000,
|
||||||
}}>
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
|
||||||
|
minWidth: 160, overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{nextStatuses.map(s => {
|
{nextStatuses.map(s => {
|
||||||
const sm = STATUS_META[s] ?? { label: s, color: '#9ca3af' };
|
const sm = STATUS_META[s] ?? { label: s, color: '#9ca3af' };
|
||||||
const isCurrent = s === currentStatus;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
@@ -93,7 +129,7 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
|
|||||||
display: 'flex', alignItems: 'center', gap: 8,
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
width: '100%', padding: '8px 12px', fontSize: 13,
|
width: '100%', padding: '8px 12px', fontSize: 13,
|
||||||
background: 'transparent', border: 'none', cursor: 'pointer',
|
background: 'transparent', border: 'none', cursor: 'pointer',
|
||||||
color: sm.color, fontWeight: isCurrent ? 700 : 400,
|
color: sm.color, fontWeight: 400,
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--surface-2)')}
|
onMouseEnter={e => (e.currentTarget.style.background = 'var(--surface-2)')}
|
||||||
@@ -101,14 +137,14 @@ export default function AppointmentStatusDropdown({ uuid, currentStatus, version
|
|||||||
>
|
>
|
||||||
<span style={{
|
<span style={{
|
||||||
width: 10, height: 10, borderRadius: '50%', flexShrink: 0,
|
width: 10, height: 10, borderRadius: '50%', flexShrink: 0,
|
||||||
background: isCurrent ? sm.color : 'transparent',
|
|
||||||
border: `2px solid ${sm.color}`,
|
border: `2px solid ${sm.color}`,
|
||||||
}} />
|
}} />
|
||||||
{sm.label}
|
{sm.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -259,11 +259,71 @@ interface SlotItem {
|
|||||||
end_time: string;
|
end_time: string;
|
||||||
is_available: boolean;
|
is_available: boolean;
|
||||||
appointment: Appointment | null;
|
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 }) {
|
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 (
|
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
|
<div
|
||||||
onClick={() => onBook(slot)}
|
onClick={() => onBook(slot)}
|
||||||
style={{
|
style={{
|
||||||
@@ -277,46 +337,39 @@ function SlotCard({ slot, queryKey, onBook }: { slot: SlotItem; queryKey: unknow
|
|||||||
>
|
>
|
||||||
<span style={{ fontSize: 18 }}>⊕</span> نوبت جدید
|
<span style={{ fontSize: 18 }}>⊕</span> نوبت جدید
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const a = slot.appointment!;
|
function SessionDivider({ startTime, endTime, count }: { startTime: string; endTime: string; count: number }) {
|
||||||
const sm = statusMeta(a.status);
|
const hour = parseInt(startTime.split(':')[0], 10);
|
||||||
|
const isAm = hour < 12;
|
||||||
|
const icon = isAm ? '🌅' : '🌆';
|
||||||
|
const label = isAm ? 'صبح' : 'عصر';
|
||||||
|
const color = isAm ? '#f59e0b' : '#6366f1';
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '4px 0 2px' }}>
|
||||||
border: `1.5px solid ${sm.color}40`, borderRadius: 'var(--r)',
|
<span style={{ fontSize: 15 }}>{icon}</span>
|
||||||
background: sm.bg, overflow: 'hidden',
|
<span style={{ fontWeight: 700, fontSize: 13, color }}>{label}</span>
|
||||||
}}>
|
<span style={{ fontSize: 12, color: 'var(--text-3)', fontWeight: 500 }}>
|
||||||
<div style={{
|
{startTime} — {endTime}
|
||||||
padding: '8px 12px', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
</span>
|
||||||
borderBottom: `1px solid ${sm.color}20`,
|
<span style={{
|
||||||
}}>
|
background: `${color}18`, borderRadius: 99, padding: '1px 8px',
|
||||||
<AppointmentStatusDropdown uuid={a.uuid} currentStatus={a.status} version={a.version} queryKey={queryKey} />
|
fontSize: 11, color, fontWeight: 600,
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-3)', fontWeight: 600 }}>
|
}}>{count} نوبت</span>
|
||||||
{slot.start_time} — {slot.end_time}
|
<div style={{ flex: 1, height: 1, background: 'var(--border)' }} />
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScheduleView({
|
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 (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={{ padding: 40, textAlign: 'center' }}>
|
||||||
<div style={{ fontSize: 32, marginBottom: 8 }}>🏖️</div>
|
<div style={{ fontSize: 32, marginBottom: 8 }}>🏖️</div>
|
||||||
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>این روز تعطیل است</div>
|
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>این روز تعطیل است</div>
|
||||||
@@ -326,16 +379,21 @@ function ScheduleView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '4px 0' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '4px 0' }}>
|
||||||
{slots.map((slot, i) => (
|
{sessions.map((session, si) => (
|
||||||
<div key={i} style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'center' }}>
|
<div key={si} style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: si > 0 ? 16 : 0 }}>
|
||||||
<SlotCard slot={slot} queryKey={queryKey} onBook={onBook} />
|
<SessionDivider startTime={session.start_time} endTime={session.end_time} count={session.slots.length} />
|
||||||
<div style={{
|
{session.slots.map((slot, i) => (
|
||||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
|
<div key={`${slot.start}-${i}`} style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'center' }}>
|
||||||
fontSize: 12, color: 'var(--text-3)', fontWeight: 600, minWidth: 48,
|
<SlotCard slot={slot} queryKey={queryKey} onBook={onBook} />
|
||||||
}}>
|
<div style={{
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--text-3)', display: 'block' }} />
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2,
|
||||||
{slot.start_time}
|
fontSize: 12, color: 'var(--text-3)', fontWeight: 600, minWidth: 48,
|
||||||
</div>
|
}}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: 'var(--text-3)', display: 'block' }} />
|
||||||
|
{slot.start_time}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -452,7 +510,7 @@ export default function AppointmentsPage() {
|
|||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const [selectedDate, setSelectedDate] = useState(today);
|
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 [selectedDoctorUuid, setSelectedDoctorUuid] = useState<string>(isDoctor && dbUuid ? dbUuid : '');
|
||||||
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
|
const [bookingSlot, setBookingSlot] = useState<BookingSlot | null>(null);
|
||||||
const [bookingHint, setBookingHint] = useState(false);
|
const [bookingHint, setBookingHint] = useState(false);
|
||||||
@@ -490,29 +548,42 @@ export default function AppointmentsPage() {
|
|||||||
enabled: viewMode === 'schedule' && !!selectedDoctorUuid,
|
enabled: viewMode === 'schedule' && !!selectedDoctorUuid,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Merge slots + appointments for schedule view
|
// ── Merge sessions + appointments for schedule view
|
||||||
const mergedSlots: SlotItem[] = React.useMemo(() => {
|
const mergedSessions: SessionGroup[] = React.useMemo(() => {
|
||||||
if (viewMode !== 'schedule') return [];
|
if (viewMode !== 'schedule') return [];
|
||||||
const rawSlots: any[] = (slotsQuery.data?.data as any)?.slots ?? [];
|
const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? [];
|
||||||
const apptByStart = new Map<number, Appointment>();
|
|
||||||
|
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 => {
|
appointments.forEach(a => {
|
||||||
const key = typeof a.slot_start === 'number' ? a.slot_start : parseInt(String(a.slot_start), 10);
|
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) => {
|
return rawSessions.map((session: any) => ({
|
||||||
const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10);
|
start_time: session.start_time as string,
|
||||||
const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10);
|
end_time: session.end_time as string,
|
||||||
const appt = apptByStart.get(slotStart) ?? null;
|
slots: (session.slots as any[]).map((s: any) => {
|
||||||
return {
|
const slotStart = typeof s.start === 'number' ? s.start : parseInt(String(s.start), 10);
|
||||||
start: slotStart,
|
const slotEnd = typeof s.end === 'number' ? s.end : parseInt(String(s.end), 10);
|
||||||
end: slotEnd,
|
return {
|
||||||
start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
start: slotStart,
|
||||||
end_time: s.end_time ?? new Date(slotEnd * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
end: slotEnd,
|
||||||
is_available: !appt,
|
start_time: s.start_time ?? new Date(slotStart * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
|
||||||
appointment: appt,
|
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]);
|
}, [viewMode, slotsQuery.data, appointments]);
|
||||||
|
|
||||||
// ── Handle slot click → open booking modal
|
// ── Handle slot click → open booking modal
|
||||||
@@ -662,7 +733,7 @@ export default function AppointmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ScheduleView
|
<ScheduleView
|
||||||
slots={mergedSlots}
|
sessions={mergedSessions}
|
||||||
loading={apptQuery.isLoading || slotsQuery.isLoading}
|
loading={apptQuery.isLoading || slotsQuery.isLoading}
|
||||||
queryKey={apptQueryKey}
|
queryKey={apptQueryKey}
|
||||||
onBook={handleSlotClick}
|
onBook={handleSlotClick}
|
||||||
|
|||||||
+26
-9
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## GET `/api/v1/appointment-slots`
|
## 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`
|
**Permission:** `PUBLIC`
|
||||||
|
|
||||||
@@ -23,23 +23,40 @@ Get available appointment slots for a doctor on a specific date.
|
|||||||
"data": {
|
"data": {
|
||||||
"doctor_uuid": "550e8400-...",
|
"doctor_uuid": "550e8400-...",
|
||||||
"date": "2024-06-15",
|
"date": "2024-06-15",
|
||||||
"slots": [
|
"sessions": [
|
||||||
{
|
{
|
||||||
"start": 1718438400,
|
"start_time": "09:00",
|
||||||
"end": 1718439600,
|
"end_time": "13:00",
|
||||||
"available": true
|
"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,
|
"start_time": "15:00",
|
||||||
"end": 1718440800,
|
"end_time": "17:00",
|
||||||
"available": false
|
"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
|
### Errors
|
||||||
| Code | HTTP | Description |
|
| Code | HTTP | Description |
|
||||||
|
|||||||
@@ -115,12 +115,12 @@ class AppointmentController extends BaseController
|
|||||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date');
|
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([
|
return $this->success([
|
||||||
'doctor_uuid' => $doctorUuid,
|
'doctor_uuid' => $doctorUuid,
|
||||||
'date' => $date,
|
'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(جمعه)
|
* 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
|
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');
|
$dayStart = (int) strtotime($date . ' 00:00:00');
|
||||||
$dayEnd = $dayStart + 86400;
|
$dayEnd = $dayStart + 86400;
|
||||||
@@ -38,10 +69,7 @@ class SlotCalculatorService
|
|||||||
foreach ($this->overrideRepo->findByDoctor($doctor) as $override) {
|
foreach ($this->overrideRepo->findByDoctor($doctor) as $override) {
|
||||||
if (date('Y-m-d', $override->getDate()) === $date) {
|
if (date('Y-m-d', $override->getDate()) === $date) {
|
||||||
if (!$override->isActive()) return [];
|
if (!$override->isActive()) return [];
|
||||||
return $this->filterBookedSlots(
|
return $this->buildSessionsFromOverride($override->getSetting() ?? [], $dayStart);
|
||||||
$doctor,
|
|
||||||
$this->buildFlatSlots($override->getSetting() ?? [], $dayStart)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,30 +84,79 @@ class SlotCalculatorService
|
|||||||
$dayConf = $schedule->getSetting()[$dayKey] ?? null;
|
$dayConf = $schedule->getSetting()[$dayKey] ?? null;
|
||||||
if ($dayConf === null) return [];
|
if ($dayConf === null) return [];
|
||||||
|
|
||||||
$sessions = $dayConf['sessions'] ?? [];
|
$sessionConfigs = $dayConf['sessions'] ?? [];
|
||||||
// Sort active sessions by start_time, skip overlapping ones
|
$activeSessions = array_filter($sessionConfigs, fn($s) => $s['active'] ?? false);
|
||||||
$activeSessions = array_filter($sessions, fn($s) => $s['active'] ?? false);
|
|
||||||
usort($activeSessions, fn($a, $b) =>
|
usort($activeSessions, fn($a, $b) =>
|
||||||
$this->parseTime($a['start_time'] ?? '00:00') <=> $this->parseTime($b['start_time'] ?? '00:00')
|
$this->parseTime($a['start_time'] ?? '00:00') <=> $this->parseTime($b['start_time'] ?? '00:00')
|
||||||
);
|
);
|
||||||
|
|
||||||
$allSlots = [];
|
$result = [];
|
||||||
$prevEnd = 0;
|
$prevEnd = 0;
|
||||||
foreach ($activeSessions as $session) {
|
foreach ($activeSessions as $session) {
|
||||||
$sessionStart = $this->parseTime($session['start_time'] ?? '00:00');
|
$sessionStart = $this->parseTime($session['start_time'] ?? '00:00');
|
||||||
if ($sessionStart < $prevEnd) continue; // skip overlapping session
|
if ($sessionStart < $prevEnd) continue; // skip overlapping session
|
||||||
$allSlots = array_merge($allSlots, $this->buildSessionSlots($session, $dayStart));
|
$slots = $this->buildSessionSlots($session, $dayStart);
|
||||||
$prevEnd = $this->parseTime($session['end_time'] ?? '00:00');
|
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 $result;
|
||||||
|
|
||||||
return $this->filterBookedSlots($doctor, $allSlots);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build slots from a morning/evening session config.
|
* Build sessions from date-override config.
|
||||||
* Supports: rest breaks, patient limits.
|
* 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
|
private function buildSessionSlots(array $session, int $dayStart): array
|
||||||
{
|
{
|
||||||
@@ -87,8 +164,8 @@ class SlotCalculatorService
|
|||||||
$endSec = $this->parseTime($session['end_time'] ?? '00:00');
|
$endSec = $this->parseTime($session['end_time'] ?? '00:00');
|
||||||
$dur = (int)($session['duration_per_patient'] ?? 20) * 60;
|
$dur = (int)($session['duration_per_patient'] ?? 20) * 60;
|
||||||
$hasRest = (bool)($session['has_rest'] ?? false);
|
$hasRest = (bool)($session['has_rest'] ?? false);
|
||||||
$restInt = (int)($session['rest_interval'] ?? 60) * 60; // convert min → sec
|
$restInt = (int)($session['rest_interval'] ?? 60) * 60;
|
||||||
$restDur = (int)($session['time_to_rest'] ?? 10) * 60; // convert min → sec
|
$restDur = (int)($session['time_to_rest'] ?? 10) * 60;
|
||||||
$limit = isset($session['patient_limit']) && $session['patient_limit'] !== null
|
$limit = isset($session['patient_limit']) && $session['patient_limit'] !== null
|
||||||
? (int)$session['patient_limit'] : null;
|
? (int)$session['patient_limit'] : null;
|
||||||
$locationId = isset($session['location_id']) ? (int)$session['location_id'] : null;
|
$locationId = isset($session['location_id']) ? (int)$session['location_id'] : null;
|
||||||
@@ -97,13 +174,12 @@ class SlotCalculatorService
|
|||||||
|
|
||||||
$slots = [];
|
$slots = [];
|
||||||
$currentSec = $startSec;
|
$currentSec = $startSec;
|
||||||
$elapsedWork = 0; // seconds worked since last rest
|
$elapsedWork = 0;
|
||||||
$patientCount = 0;
|
$patientCount = 0;
|
||||||
|
|
||||||
while ($currentSec + $dur <= $endSec) {
|
while ($currentSec + $dur <= $endSec) {
|
||||||
if ($limit !== null && $patientCount >= $limit) break;
|
if ($limit !== null && $patientCount >= $limit) break;
|
||||||
|
|
||||||
// Insert rest break if needed
|
|
||||||
if ($hasRest && $restInt > 0 && $elapsedWork > 0 && $elapsedWork >= $restInt) {
|
if ($hasRest && $restInt > 0 && $elapsedWork > 0 && $elapsedWork >= $restInt) {
|
||||||
$currentSec += $restDur;
|
$currentSec += $restDur;
|
||||||
$elapsedWork = 0;
|
$elapsedWork = 0;
|
||||||
@@ -126,41 +202,6 @@ class SlotCalculatorService
|
|||||||
return $slots;
|
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
|
private function filterBookedSlots(Doctor $doctor, array $slots): array
|
||||||
{
|
{
|
||||||
return array_values(array_filter($slots, fn(array $slot): bool =>
|
return array_values(array_filter($slots, fn(array $slot): bool =>
|
||||||
|
|||||||
Reference in New Issue
Block a user